Add GTP hub (code bomb).
diff mbox

Message ID 1446602182-32618-2-git-send-email-nhofmeyr@sysmocom.de
State Superseded
Headers show

Commit Message

Neels Hofmeyr Nov. 4, 2015, 1:56 a.m. UTC
First steps towards a new GTP hub. The aim is to mux GTP connections, so that
multiple SGSN <--> GGSN links can pass through a single point. Background:
allow having more than one SGSN, possibly in various remote locations.

The recent addition of OAP to GSUP is related to the same background idea.

(This is a collapsed patch of various changes that do not make sense to review
in chronological order anymore, since a lot of it has thorougly transmorphed
after it was first committed.)

Sponsored-by: On-Waves ehf
---
 openbsc/.gitignore                              |    2 +
 openbsc/configure.ac                            |    1 +
 openbsc/include/openbsc/Makefile.am             |    1 +
 openbsc/include/openbsc/debug.h                 |    1 +
 openbsc/include/openbsc/gtphub.h                |  345 +++++
 openbsc/include/openbsc/vty.h                   |    1 +
 openbsc/src/gprs/Makefile.am                    |    6 +
 openbsc/src/gprs/gtphub.c                       | 1794 +++++++++++++++++++++++
 openbsc/src/gprs/gtphub_main.c                  |  283 ++++
 openbsc/src/gprs/gtphub_sep.c                   |   26 +
 openbsc/src/gprs/gtphub_vty.c                   |  258 ++++
 openbsc/tests/Makefile.am                       |    2 +-
 openbsc/tests/gtphub/Makefile.am                |   20 +
 openbsc/tests/gtphub/gtphub_nc_test.gtphub.conf |    5 +
 openbsc/tests/gtphub/gtphub_nc_test.ok          |    7 +
 openbsc/tests/gtphub/gtphub_nc_test.sh          |   85 ++
 openbsc/tests/gtphub/gtphub_test.c              |  675 +++++++++
 openbsc/tests/gtphub/gtphub_test.ok             |    3 +
 openbsc/tests/gtphub/hex2bin.py                 |   13 +
 openbsc/tests/testsuite.at                      |   12 +
 20 files changed, 3539 insertions(+), 1 deletion(-)
 create mode 100644 openbsc/include/openbsc/gtphub.h
 create mode 100644 openbsc/src/gprs/gtphub.c
 create mode 100644 openbsc/src/gprs/gtphub_main.c
 create mode 100644 openbsc/src/gprs/gtphub_sep.c
 create mode 100644 openbsc/src/gprs/gtphub_vty.c
 create mode 100644 openbsc/tests/gtphub/Makefile.am
 create mode 100644 openbsc/tests/gtphub/gtphub_nc_test.gtphub.conf
 create mode 100644 openbsc/tests/gtphub/gtphub_nc_test.ok
 create mode 100755 openbsc/tests/gtphub/gtphub_nc_test.sh
 create mode 100644 openbsc/tests/gtphub/gtphub_test.c
 create mode 100644 openbsc/tests/gtphub/gtphub_test.ok
 create mode 100755 openbsc/tests/gtphub/hex2bin.py

Comments

Neels Hofmeyr Nov. 4, 2015, 2:01 a.m. UTC | #1
On Mon, Nov 02, 2015 at 08:20:39PM +0100, Holger Freyther wrote:
> > To reiterate, I'd like to know whether gtphub should/really must ;) be
> > changed to the latter style.
> 
> yes.

ok, the patch I just sent still uses my style though. I'll modify it
later. And also I'm aware that my special LOG() makros are still in there,
too. Not forgotten. Hope that doesn't harm reviews.

~Neels

Patch
diff mbox

diff --git a/openbsc/.gitignore b/openbsc/.gitignore
index ca73db6..fc3d0bf 100644
--- a/openbsc/.gitignore
+++ b/openbsc/.gitignore
@@ -53,6 +53,7 @@  src/utils/isdnsync
 src/nat/bsc_nat
 src/gprs/osmo-sgsn
 src/gprs/osmo-gbproxy
+src/gprs/osmo-gtphub
 src/osmo-bsc_nat/osmo-bsc_nat
 
 #tests
@@ -78,6 +79,7 @@  tests/mgcp/mgcp_transcoding_test
 tests/sgsn/sgsn_test
 tests/subscr/subscr_test
 tests/oap/oap_test
+tests/gtphub/gtphub_test
 
 tests/atconfig
 tests/atlocal
diff --git a/openbsc/configure.ac b/openbsc/configure.ac
index 8b7ce62..098e5b4 100644
--- a/openbsc/configure.ac
+++ b/openbsc/configure.ac
@@ -210,6 +210,7 @@  AC_OUTPUT(
     tests/sgsn/Makefile
     tests/subscr/Makefile
     tests/oap/Makefile
+    tests/gtphub/Makefile
     doc/Makefile
     doc/examples/Makefile
     Makefile)
diff --git a/openbsc/include/openbsc/Makefile.am b/openbsc/include/openbsc/Makefile.am
index 8a074c2..15c38d1 100644
--- a/openbsc/include/openbsc/Makefile.am
+++ b/openbsc/include/openbsc/Makefile.am
@@ -18,6 +18,7 @@  noinst_HEADERS = abis_nm.h abis_rsl.h db.h gsm_04_08.h gsm_data.h \
 		 gprs_gb_parse.h smpp.h meas_feed.h gprs_gsup_messages.h \
 		 gprs_gsup_client.h bsc_msg_filter.h \
 		 oap.h oap_messages.h
+		 gtphub.h
 
 openbsc_HEADERS = gsm_04_08.h meas_rep.h bsc_api.h
 openbscdir = $(includedir)/openbsc
diff --git a/openbsc/include/openbsc/debug.h b/openbsc/include/openbsc/debug.h
index 19d8fc2..189ca47 100644
--- a/openbsc/include/openbsc/debug.h
+++ b/openbsc/include/openbsc/debug.h
@@ -33,6 +33,7 @@  enum {
 	DCTRL,
 	DSMPP,
 	DFILTER,
+	DGTPHUB,
 	Debug_LastEntry,
 };
 
diff --git a/openbsc/include/openbsc/gtphub.h b/openbsc/include/openbsc/gtphub.h
new file mode 100644
index 0000000..ebd4058
--- /dev/null
+++ b/openbsc/include/openbsc/gtphub.h
@@ -0,0 +1,345 @@ 
+/* GTP Hub Implementation */
+
+/* (C) 2015 by sysmocom s.f.m.c. GmbH <info@sysmocom.de>
+ * All Rights Reserved
+ *
+ * Author: Neels Hofmeyr
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <stdint.h>
+#include <sys/socket.h>
+
+#include <osmocom/core/select.h>
+#include <osmocom/core/timer.h>
+
+
+/* support */
+
+/* TODO move to osmocom/core/socket.c ? */
+#include <netdb.h> /* for IPPROTO_* etc */
+struct osmo_sockaddr {
+	struct sockaddr_storage a;
+	socklen_t l;
+};
+
+/* TODO move to osmocom/core/socket.c ? */
+/*! \brief Initialize a sockaddr
+ * \param[out] addr  Valid osmo_sockaddr pointer to write result to
+ * \param[in] family  Address Family like AF_INET, AF_INET6, AF_UNSPEC
+ * \param[in] type  Socket type like SOCK_DGRAM, SOCK_STREAM
+ * \param[in] proto  Protocol like IPPROTO_TCP, IPPROTO_UDP
+ * \param[in] host Remote host name or IP address in string form
+ * \param[in] port Remote port number in host byte order
+ * \returns 0 on success, otherwise an error code (from getaddrinfo()).
+ *
+ * Copy the first result from a getaddrinfo() call with the given parameters to
+ * *addr and *addr_len. On error, do not change *addr and return nonzero.
+ */
+int osmo_sockaddr_init(struct osmo_sockaddr *addr,
+		       uint16_t family, uint16_t type, uint8_t proto,
+		       const char *host, uint16_t port);
+
+/* Conveniently pass AF_UNSPEC, SOCK_DGRAM and IPPROTO_UDP to
+ * osmo_sockaddr_init(). */
+static inline int osmo_sockaddr_init_udp(struct osmo_sockaddr *addr,
+					 const char *host, uint16_t port)
+{
+	return osmo_sockaddr_init(addr, AF_UNSPEC, SOCK_DGRAM, IPPROTO_UDP, host, port);
+}
+
+/*! \brief convert sockaddr to human readable string.
+ * \param[out] addr_str  Valid pointer to a buffer of length addr_str_len.
+ * \param[in] addr_str_len  Size of buffer addr_str points at.
+ * \param[out] port_str  Valid pointer to a buffer of length port_str_len.
+ * \param[in] port_str_len  Size of buffer port_str points at.
+ * \param[in] addr  Binary representation as returned by osmo_sockaddr_init().
+ * \param[in] flags  flags as passed to getnameinfo().
+ * \returns  0 on success, an error code on error.
+ *
+ * Return the IPv4 or IPv6 address string and the port (a.k.a. service) string
+ * representations of the given struct osmo_sockaddr in two caller provided
+ * char buffers. Flags of (NI_NUMERICHOST | NI_NUMERICSERV) return numeric
+ * address and port. Either one of addr_str or port_str may be NULL, in which
+ * case nothing is returned there.
+ *
+ * See also osmo_sockaddr_to_str() (less flexible, but much more convenient). */
+int osmo_sockaddr_to_strs(char *addr_str, size_t addr_str_len,
+			  char *port_str, size_t port_str_len,
+			  const struct osmo_sockaddr *addr,
+			  int flags);
+
+
+/*! \brief conveniently concatenate the parts returned by osmo_sockaddr_to_strs().
+ * \param[in] addr  Binary representation as returned by osmo_sockaddr_init().
+ * \param[in] buf  A buffer to use for string operations.
+ * \param[in] buf_len  Length of the buffer.
+ * \returns  Address string (in buffer).
+ *
+ * Compose a string of the numeric IP-address and port represented by *addr of
+ * the form "<ip-addr> port <port>". The returned string is valid until the
+ * next invocation of this function.
+ */
+const char *osmo_sockaddr_to_strb(const struct osmo_sockaddr *addr,
+				  char *buf, size_t buf_len);
+
+/*! \brief conveniently return osmo_sockaddr_to_strb() in a static buffer.
+ * \param[in] addr  Binary representation as returned by osmo_sockaddr_init().
+ * \returns  Address string in static buffer.
+ *
+ * See osmo_sockaddr_to_strb().
+ *
+ * Note: only one osmo_sockaddr_to_str() call will work per print/log
+ * statement. For two or more, use osmo_sockaddr_to_strb() with a separate
+ * buffer each.
+ */
+const char *osmo_sockaddr_to_str(const struct osmo_sockaddr *addr);
+
+/*! \brief compare two osmo_sockaddr.
+ * \param[in] a  The first address to compare.
+ * \param[in] b  The other address to compare.
+ * \returns 0 if equal, otherwise -1 or 1.
+ */
+int osmo_sockaddr_cmp(const struct osmo_sockaddr *a, const struct osmo_sockaddr *b);
+
+/*! \brief Overwrite *dst with *src.
+ * Like memcpy(), but copy only the valid bytes. */
+void osmo_sockaddr_copy(struct osmo_sockaddr *dst, const struct osmo_sockaddr *src);
+
+
+/* general */
+
+enum gtphub_port_idx {
+	GTPH_PORT_CTRL = 0,
+	GTPH_PORT_USER = 1,
+	GTPH_PORT_N
+};
+
+extern const char* const gtphub_port_idx_names[GTPH_PORT_N];
+
+
+/* A number map assigns a "random" mapped number to each user provided number.
+ * If the same number is requested multiple times, the same mapped number is
+ * returned.
+ *
+ * Number maps plug into possibly shared pools and expiry queues, for example:
+ *
+ *     mapA -----------+-> pool1           <-+-- mapB
+ *     {10->1, 11->5}  |   {1, 2, 3, ...}    |   {10->2, 11->3}
+ *                     |                     |
+ *                     |                     |
+ *                 /-> \-> expiry1         <-/
+ *                 |       (30 seconds)
+ *                 |
+ *     mapC -------+-----> pool2           <-+-- mapD
+ *     {10->1, 11->3}      {1, 2, 3, ...}    |   {10->2, 11->5}
+ *                                           |
+ *                         expiry2         <-/
+ *                         (60 seconds)
+ *
+ * A map contains mappings ("10->1"). Each map needs a number pool, which can
+ * be shared with other maps. Each new mapping receives a number from the pool,
+ * which is then unavailable to any other map using the same pool.
+ *
+ * A map may point at an expiry queue, in which case all mappings added to it
+ * are also appended to the expiry queue (using a separate llist entry in the
+ * mapping). Any number of maps may submit to the same expiry queue, if they
+ * desire the same expiry timeout. An expiry queue stores the mappings in
+ * chronological order, so that expiry checking is needed only from the start
+ * of the queue; hence only mappings with identical expiry timeout can be added
+ * to the same expiry queue. Upon expiry, a mapping is dropped from the map it
+ * was submitted at. nr_map_expiry_tick() needs to be called regularly for
+ * each expiry queue.
+ *
+ * A nr_mapping can be embedded in a larger struct: each mapping can have a
+ * distinct destructor (del_cb), and each del_cb can figure out the container
+ * struct's address and free that upon expiry or manual deletion. So in expiry
+ * queues (and even maps), mappings of different container types can be mixed.
+ * This can help to drastically reduce the amount of unnecessary visits during
+ * expiry checking, for the case that no expiry is pending. An expiry queue
+ * always knows which mappings to expire next, because they are right at the
+ * start of its list.
+ *
+ * Mapping allocation and a del_cb are provided by the caller. If del_cb is
+ * NULL, no deallocation will be done (allowing statically allocated entries).
+ */
+/* TODO at some point I thought the allocation & del_cb complexity was
+ * needed/helpful, but by now it seems like overkill. Maybe lose that again. */
+
+typedef int nr_t;
+
+/* Generator for unused numbers. So far this counts upwards from zero, but the
+ * implementation may change in the future. Treat this like an opaque struct.
+ * If this becomes random, the tests need to be fixed. */
+struct nr_pool {
+	nr_t last_nr;
+	/* TODO add min, max, for safe wrapping */
+};
+
+struct nr_mapping;
+typedef void (*nr_mapping_del_cb_t)(struct nr_mapping *);
+
+struct nr_mapping {
+	struct llist_head entry;
+	struct llist_head expiry_entry;
+	time_t expiry;
+
+	void *origin;
+	nr_t orig;
+	nr_t repl;
+
+	nr_mapping_del_cb_t del_cb;
+};
+
+struct nr_map_expiry {
+	int expiry_in_seconds;
+	struct llist_head mappings;
+};
+
+struct nr_map {
+	struct nr_pool *pool; /* multiple nr_maps can share a nr_pool. */
+	struct nr_map_expiry *expiry;
+	struct llist_head mappings;
+};
+
+
+void nr_pool_init(struct nr_pool *pool);
+
+/* Return the next unused number from the nr_pool. */
+nr_t nr_pool_next(struct nr_pool *pool);
+
+/* Initialize the nr_mapping to zero/empty values. */
+void nr_mapping_init(struct nr_mapping *mapping);
+
+/* Remove the given mapping from its parent map and expiry queue, and call
+ * mapping->del_cb, if set. */
+void nr_mapping_del(struct nr_mapping *mapping);
+
+/* Initialize an expiry queue exq. */
+void nr_map_expiry_init(struct nr_map_expiry *exq, int expiry_in_seconds);
+
+/* Add a new mapping, or restart the expiry timeout for an already listed mapping. */
+void nr_map_expiry_add(struct nr_map_expiry *exq, struct nr_mapping *mapping, time_t now);
+
+/* Carry out due expiry of mappings. Must be invoked regularly.
+ * 'now' is the current clock count in seconds and must correspond to the clock
+ * count passed to nr_map_add(). A monotonous clock counter should be used. */
+int nr_map_expiry_tick(struct nr_map_expiry *exq, time_t now);
+
+/* Initialize an (already allocated) nr_map, and set the map's number pool.
+ * Multiple nr_map instances may use the same nr_pool. Set the nr_map's expiry
+ * queue to exq, so that all added mappings are automatically expired after the
+ * time configured in exq. exq may be NULL to disable automatic expiry. */
+void nr_map_init(struct nr_map *map, struct nr_pool *pool,
+		 struct nr_map_expiry *exq);
+
+/* Add a new entry to the map. mapping->orig, mapping->origin and
+ * mapping->del_cb must be set before calling this function. The remaining
+ * fields of *mapping will be overwritten. mapping->repl is set to the next
+ * available mapped number from map->pool. 'now' is the current clock count in
+ * seconds; if no map->expiry is used, just pass 0 for 'now'. */
+void nr_map_add(struct nr_map *map, struct nr_mapping *mapping,
+		time_t now);
+
+/* Return a known mapping from nr_orig and the given origin. If nr_orig is
+ * unknown, return NULL. */
+struct nr_mapping *nr_map_get(const struct nr_map *map,
+			      void *origin, nr_t nr_orig);
+
+/* Return a known mapping to nr_repl. If nr_repl is unknown, return NULL. */
+struct nr_mapping *nr_map_get_inv(const struct nr_map *map, nr_t nr_repl);
+
+/* Remove all mappings from map. */
+void nr_map_clear(struct nr_map *map);
+
+/* Return 1 if map has no entries, 0 otherwise. */
+int nr_map_empty(const struct nr_map *map);
+
+
+/* config */
+
+static const int GTPH_SEQ_MAPPING_EXPIRY_SECS = 30; /* TODO is there a spec for this? */
+static const int GTPH_TEI_MAPPING_EXPIRY_MINUTES = 6 * 60; /* TODO is there a spec for this? */
+
+struct gtphub_cfg_addr {
+	const char *addr_str;
+	uint16_t port;
+};
+
+struct gtphub_cfg_bind {
+	struct gtphub_cfg_addr bind;
+};
+
+struct gtphub_cfg {
+	struct gtphub_cfg_bind to_sgsns[GTPH_PORT_N];
+	struct gtphub_cfg_bind to_ggsns[GTPH_PORT_N];
+	struct gtphub_cfg_addr sgsn_proxy[GTPH_PORT_N];
+	struct gtphub_cfg_addr ggsn_proxy[GTPH_PORT_N];
+};
+
+
+/* state */
+
+struct gtphub_peer {
+	struct llist_head entry;
+
+	struct osmo_sockaddr addr;
+	struct nr_map tei_map;
+	struct nr_pool seq_pool;
+	struct nr_map seq_map;
+	unsigned int ref_count; /* references from other peers' seq_maps */
+	struct gtphub_peer *association[GTPH_PORT_N]; /* One points to "this" */
+};
+
+struct gtphub_bind {
+	struct osmo_fd ofd;
+	struct nr_pool tei_pool;
+
+	/* list of struct gtphub_peer */
+	struct llist_head peers;
+};
+
+struct gtphub {
+	struct gtphub_bind to_sgsns[GTPH_PORT_N];
+	struct gtphub_bind to_ggsns[GTPH_PORT_N];
+
+	/* pointers to an entry of to_sgsns[x].peers */
+	struct gtphub_peer *sgsn_proxy[GTPH_PORT_N];
+
+	/* pointers to an entry of to_ggsns[x].peers */
+	struct gtphub_peer *ggsn_proxy[GTPH_PORT_N];
+
+	struct osmo_timer_list gc_timer;
+	struct nr_map_expiry expire_seq_maps;
+	struct nr_map_expiry expire_tei_maps;
+};
+
+struct gtp_packet_desc;
+
+
+/* api */
+
+int gtphub_vty_init(void);
+int gtphub_cfg_read(struct gtphub_cfg *cfg, const char *config_file);
+
+/* Initialize and start gtphub: bind to ports, run expiry timers. */
+int gtphub_start(struct gtphub *hub, struct gtphub_cfg *cfg);
+
+time_t gtphub_now(void);
+
+/* Remove expired items, empty peers, ... */
+void gtphub_gc(struct gtphub *hub, time_t now);
diff --git a/openbsc/include/openbsc/vty.h b/openbsc/include/openbsc/vty.h
index 818a20e..bc30e23 100644
--- a/openbsc/include/openbsc/vty.h
+++ b/openbsc/include/openbsc/vty.h
@@ -36,6 +36,7 @@  enum bsc_vty_node {
 	BSC_NODE,
 	SMPP_NODE,
 	SMPP_ESME_NODE,
+	GTPHUB_NODE,
 };
 
 extern int bsc_vty_is_config_node(struct vty *vty, int node);
diff --git a/openbsc/src/gprs/Makefile.am b/openbsc/src/gprs/Makefile.am
index c8e3696..b2d1774 100644
--- a/openbsc/src/gprs/Makefile.am
+++ b/openbsc/src/gprs/Makefile.am
@@ -11,6 +11,7 @@  noinst_HEADERS = gprs_sndcp.h
 bin_PROGRAMS = osmo-gbproxy
 
 if HAVE_LIBGTP
+bin_PROGRAMS += osmo-gtphub
 if HAVE_LIBCARES
 bin_PROGRAMS += osmo-sgsn
 endif
@@ -33,3 +34,8 @@  osmo_sgsn_LDADD = 	\
 			$(top_builddir)/src/libcommon/libcommon.a \
 			-lgtp $(OSMO_LIBS) $(LIBOSMOABIS_LIBS) $(LIBCARES_LIBS) \
 			$(LIBCRYPTO_LIBS) -lrt
+
+osmo_gtphub_SOURCES =	gtphub_main.c gtphub.c gtphub_sep.c gtphub_vty.c
+osmo_gtphub_LDADD = 	\
+			$(top_builddir)/src/libcommon/libcommon.a \
+			-lgtp $(LIBOSMOCORE_LIBS) $(LIBOSMOVTY_LIBS) -lrt
diff --git a/openbsc/src/gprs/gtphub.c b/openbsc/src/gprs/gtphub.c
new file mode 100644
index 0000000..19246e2
--- /dev/null
+++ b/openbsc/src/gprs/gtphub.c
@@ -0,0 +1,1794 @@ 
+/* GTP Hub Implementation */
+
+/* (C) 2015 by sysmocom s.f.m.c. GmbH <info@sysmocom.de>
+ * All Rights Reserved
+ *
+ * Author: Neels Hofmeyr
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <string.h>
+#include <errno.h>
+#include <inttypes.h>
+#include <time.h>
+#include <limits.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+
+#include <gtp.h>
+#include <gtpie.h>
+
+#include <openbsc/gtphub.h>
+#include <openbsc/debug.h>
+
+#include <osmocom/core/utils.h>
+#include <osmocom/core/logging.h>
+#include <osmocom/core/socket.h>
+
+#define GTPHUB_DEBUG 1
+
+static const int GTPH_GC_TICK_SECONDS = 1;
+
+void *osmo_gtphub_ctx;
+
+#define LOGERR(fmt, args...) \
+	LOGP(DGTPHUB, LOGL_ERROR, fmt, ##args)
+
+#define LOG(fmt, args...) \
+	LOGP(DGTPHUB, LOGL_NOTICE, fmt, ##args)
+
+#define ZERO_STRUCT(struct_pointer) memset(struct_pointer, '\0', sizeof(*(struct_pointer)))
+
+/* TODO move this to osmocom/core/select.h ? */
+typedef int (*osmo_fd_cb_t)(struct osmo_fd *fd, unsigned int what);
+
+/* TODO move this to osmocom/core/linuxlist.h ? */
+#define __llist_first(head) (((head)->next == (head)) ? NULL : (head)->next)
+#define llist_first(head, type, entry) llist_entry(__llist_first(head), type, entry)
+
+/* TODO move GTP header stuff to openggsn/gtp/ ? See gtp_decaps*() */
+
+enum gtp_rc {
+	GTP_RC_UNKNOWN = 0,
+	GTP_RC_TINY = 1,    /* no IEs (like ping/pong) */
+	GTP_RC_PDU = 2,     /* a real packet with IEs */
+
+	GTP_RC_TOOSHORT = -1,
+	GTP_RC_UNSUPPORTED_VERSION = -2,
+	GTP_RC_INVALID_IE = -3,
+};
+
+struct gtp_packet_desc {
+	union gtp_packet *data;
+	int data_len;
+	int header_len;
+	int version;
+	int rc; /* enum gtp_rc */
+	unsigned int port_idx;
+	union gtpie_member *ie[GTPIE_SIZE];
+};
+
+/* Validate GTP version 0 data; analogous to validate_gtp1_header(), see there.
+ */
+void validate_gtp0_header(struct gtp_packet_desc *p)
+{
+	const struct gtp0_header *pheader = &(p->data->gtp0.h);
+	p->rc = GTP_RC_UNKNOWN;
+	p->header_len = 0;
+
+	OSMO_ASSERT(p->data_len >= 1);
+	OSMO_ASSERT(p->version == 0);
+
+	if (p->data_len < GTP0_HEADER_SIZE) {
+		LOGERR("GTP0 packet too short: %d\n", p->data_len);
+		p->rc = GTP_RC_TOOSHORT;
+		return;
+	}
+
+	if (p->data_len == GTP0_HEADER_SIZE) {
+		p->rc = GTP_RC_TINY;
+		p->header_len = GTP0_HEADER_SIZE;
+		return;
+	}
+
+	/* Check packet length field versus length of packet */
+	if (p->data_len != (ntoh16(pheader->length) + GTP0_HEADER_SIZE)) {
+		LOGERR("GTP packet length field (%d + %d) does not match"
+		       " actual length (%d)\n",
+		       GTP0_HEADER_SIZE, (int)ntoh16(pheader->length),
+		       p->data_len);
+		p->rc = GTP_RC_TOOSHORT;
+		return;
+	}
+
+	LOG("GTP v0 TID = %" PRIu64 "\n", pheader->tid);
+	p->header_len = GTP0_HEADER_SIZE;
+	p->rc = GTP_RC_PDU;
+}
+
+/* Validate GTP version 1 data, and update p->rc with the result, as well as
+ * p->header_len in case of a valid header. */
+void validate_gtp1_header(struct gtp_packet_desc *p)
+{
+	const struct gtp1_header_long *pheader = &(p->data->gtp1l.h);
+	p->rc = GTP_RC_UNKNOWN;
+	p->header_len = 0;
+
+	OSMO_ASSERT(p->data_len >= 1);
+	OSMO_ASSERT(p->version == 1);
+
+	if ((p->data_len < GTP1_HEADER_SIZE_LONG)
+	    && (p->data_len != GTP1_HEADER_SIZE_SHORT)){
+		LOGERR("GTP packet too short: %d\n", p->data_len);
+		p->rc = GTP_RC_TOOSHORT;
+		return;
+	}
+
+	LOG("|GTPv1\n");
+	LOG("| type = %" PRIu8 " 0x%02" PRIx8 "\n",
+	    pheader->type, pheader->type);
+	LOG("| length = %" PRIu16 " 0x%04" PRIx16 "\n",
+	    ntoh16(pheader->length), ntoh16(pheader->length));
+	LOG("| TEI = %" PRIu32 " 0x%08" PRIx32 "\n",
+	    ntoh32(pheader->tei), ntoh32(pheader->tei));
+	LOG("| seq = %" PRIu16 " 0x%04" PRIx16 "\n",
+	    ntoh16(pheader->seq), ntoh16(pheader->seq));
+	LOG("| npdu = %" PRIu8 " 0x%02" PRIx8 "\n",
+	    pheader->npdu, pheader->npdu);
+	LOG("| next = %" PRIu8 " 0x%02" PRIx8 "\n",
+	    pheader->next, pheader->next);
+
+	if (p->data_len <= GTP1_HEADER_SIZE_LONG) {
+		p->rc = GTP_RC_TINY;
+		p->header_len = GTP1_HEADER_SIZE_SHORT;
+		return;
+	}
+
+	/* Check packet length field versus length of packet */
+	if (p->data_len != (ntoh16(pheader->length) + GTP1_HEADER_SIZE_SHORT)) {
+		LOGERR("GTP packet length field (%d + %d) does not match"
+		       " actual length (%d)\n",
+		       GTP1_HEADER_SIZE_SHORT, (int)ntoh16(pheader->length),
+		       p->data_len);
+		p->rc = GTP_RC_TOOSHORT;
+		return;
+	}
+
+	p->rc = GTP_RC_PDU;
+	p->header_len = GTP1_HEADER_SIZE_LONG;
+}
+
+/* Examine whether p->data of size p->data_len has a valid GTP header. Set
+ * p->version, p->rc and p->header_len. On error, p->rc <= 0 (see enum
+ * gtp_rc). p->data must point at a buffer with p->data_len set. */
+void validate_gtp_header(struct gtp_packet_desc *p)
+{
+	p->rc = GTP_RC_UNKNOWN;
+
+	/* Need at least 1 byte in order to check version */
+	if (p->data_len < 1) {
+		LOGERR("Discarding packet - too small: %d\n", p->data_len);
+		p->rc = GTP_RC_TOOSHORT;
+		return;
+	}
+
+	p->version = p->data->flags >> 5;
+
+	switch (p->version) {
+	case 0:
+		validate_gtp0_header(p);
+		break;
+	case 1:
+		validate_gtp1_header(p);
+		break;
+	default:
+		LOGERR("Unsupported GTP version: %d\n", p->version);
+		p->rc = GTP_RC_UNSUPPORTED_VERSION;
+		break;
+	}
+}
+
+
+/* Return the value of the i'th IMSI IEI by copying to *imsi.
+ * The first IEI is reached by passing i = 0.
+ * imsi must point at allocated space of (at least) 8 bytes.
+ * Return 1 on success, or 0 if not found. */
+static int get_ie_imsi(union gtpie_member *ie[], uint8_t *imsi, int i)
+{
+	return gtpie_gettv0(ie, GTPIE_IMSI, i, imsi, 8) == 0;
+}
+
+/* Analogous to get_ie_imsi(). nsapi must point at a single uint8_t. */
+static int get_ie_nsapi(union gtpie_member *ie[], uint8_t *nsapi, int i)
+{
+	return gtpie_gettv1(ie, GTPIE_NSAPI, i, nsapi) == 0;
+}
+
+static char imsi_digit_to_char(uint8_t nibble)
+{
+	nibble &= 0x0f;
+	if (nibble > 9)
+		return (nibble == 0x0f) ? '\0' : '?';
+	return '0' + nibble;
+}
+
+/* Return a human readable IMSI string, in a static buffer.
+ * imsi must point at 8 octets of IMSI IE encoded IMSI data. */
+static const char *imsi_to_str(uint8_t *imsi)
+{
+	static char str[17];
+	int i;
+
+	for (i = 0; i < 8; i++) {
+		str[2*i] = imsi_digit_to_char(imsi[i]);
+		str[2*i + 1] = imsi_digit_to_char(imsi[i] >> 4);
+	}
+	str[16] = '\0';
+	return str;
+}
+
+/* Validate header, and index information elements. Write decoded packet
+ * information to *res. res->data will point at the given data buffer. On
+ * error, p->rc is set <= 0 (see enum gtp_rc). */
+static void gtp_decode(const uint8_t *data, int data_len,
+		       unsigned int from_port_idx,
+		       struct gtp_packet_desc *res)
+{
+	ZERO_STRUCT(res);
+	res->data = (union gtp_packet*)data;
+	res->data_len = data_len;
+	res->port_idx = from_port_idx;
+
+	validate_gtp_header(res);
+
+	if (res->rc <= 0) {
+		LOGERR("INVALID: dropping GTP packet.\n");
+		return;
+	}
+
+	LOG("Valid GTP header (v%d)\n", res->version);
+
+	if (res->rc != GTP_RC_PDU) {
+		LOG("no IEs in this GTP packet\n");
+		return;
+	}
+
+	if (gtpie_decaps(res->ie, res->version,
+			 (void*)(data + res->header_len),
+			 res->data_len - res->header_len) != 0) {
+		res->rc = GTP_RC_INVALID_IE;
+		return;
+	}
+
+#if GTPHUB_DEBUG
+	int i;
+
+	for (i = 0; i < 10; i++) {
+		uint8_t imsi[8];
+		if (!get_ie_imsi(res->ie, imsi, i))
+			break;
+		LOG("| IMSI %s\n", imsi_to_str(imsi));
+	}
+
+	for (i = 0; i < 10; i++) {
+		uint8_t nsapi;
+		if (!get_ie_nsapi(res->ie, &nsapi, i))
+			break;
+		LOG("| NSAPI %d\n", (int)nsapi);
+	}
+
+	for (i = 0; i < 10; i++) {
+		unsigned int addr_len;
+		struct in_addr addr;
+		if (gtpie_gettlv(res->ie, GTPIE_GSN_ADDR, i, &addr_len, &addr,
+				 sizeof(addr)) != 0)
+			break;
+		LOG("| addr %s\n", inet_ntoa(addr));
+	}
+
+	for (i = 0; i < 10; i++) {
+		uint32_t tei;
+		if (gtpie_gettv4(res->ie, GTPIE_TEI_DI, i, &tei) != 0)
+			break;
+		LOG("| TEI DI (USER) %" PRIu32 " 0x%08" PRIx32 "\n",
+		    tei, tei);
+	}
+
+	for (i = 0; i < 10; i++) {
+		uint32_t tei;
+		if (gtpie_gettv4(res->ie, GTPIE_TEI_C, i, &tei) != 0)
+			break;
+		LOG("| TEI (CTRL) %" PRIu32 " 0x%08" PRIx32 "\n",
+		    tei, tei);
+	}
+#endif
+}
+
+
+/* general */
+
+const char* const gtphub_port_idx_names[GTPH_PORT_N] = {
+	"CTRL",
+	"USER",
+};
+
+time_t gtphub_now(void)
+{
+	struct timespec now_tp;
+	OSMO_ASSERT(clock_gettime(CLOCK_MONOTONIC, &now_tp) >= 0);
+	return now_tp.tv_sec;
+}
+
+
+/* nr_map, nr_pool */
+
+void nr_pool_init(struct nr_pool *pool)
+{
+	*pool = (struct nr_pool){};
+}
+
+nr_t nr_pool_next(struct nr_pool *pool)
+{
+	pool->last_nr ++;
+
+	OSMO_ASSERT(pool->last_nr > 0);
+	/* TODO: gracefully handle running out of TEIs. */
+	/* TODO: random TEIs. */
+
+	return pool->last_nr;
+}
+
+void nr_map_init(struct nr_map *map, struct nr_pool *pool,
+		 struct nr_map_expiry *exq)
+{
+	ZERO_STRUCT(map);
+	map->pool = pool;
+	map->expiry = exq;
+	INIT_LLIST_HEAD(&map->mappings);
+}
+
+void nr_mapping_init(struct nr_mapping *m)
+{
+	ZERO_STRUCT(m);
+	INIT_LLIST_HEAD(&m->entry);
+	INIT_LLIST_HEAD(&m->expiry_entry);
+}
+
+void nr_map_add(struct nr_map *map, struct nr_mapping *mapping, time_t now)
+{
+	/* Generate a mapped number */
+	mapping->repl = nr_pool_next(map->pool);
+
+	/* Add to the tail to always yield a list sorted by expiry, in
+	 * ascending order. */
+	llist_add_tail(&mapping->entry, &map->mappings);
+	if (map->expiry)
+		nr_map_expiry_add(map->expiry, mapping, now);
+}
+
+void nr_map_clear(struct nr_map *map)
+{
+	struct nr_mapping *m;
+	struct nr_mapping *n;
+	llist_for_each_entry_safe(m, n, &map->mappings, entry) {
+		nr_mapping_del(m);
+	}
+}
+
+int nr_map_empty(const struct nr_map *map)
+{
+	return llist_empty(&map->mappings);
+}
+
+struct nr_mapping *nr_map_get(const struct nr_map *map,
+			      void *origin, nr_t nr_orig)
+{
+	struct nr_mapping *mapping;
+	llist_for_each_entry(mapping, &map->mappings, entry) {
+		if ((mapping->origin == origin)
+		    && (mapping->orig == nr_orig))
+			return mapping;
+	}
+	/* Not found. */
+	return NULL;
+}
+
+struct nr_mapping *nr_map_get_inv(const struct nr_map *map, nr_t nr_repl)
+{
+	struct nr_mapping *mapping;
+	llist_for_each_entry(mapping, &map->mappings, entry) {
+		if (mapping->repl == nr_repl) {
+			return mapping;
+		}
+	}
+	/* Not found. */
+	return NULL;
+}
+
+void nr_mapping_del(struct nr_mapping *mapping)
+{
+	OSMO_ASSERT(mapping);
+	llist_del(&mapping->entry);
+	llist_del(&mapping->expiry_entry);
+	if (mapping->del_cb)
+		(mapping->del_cb)(mapping);
+}
+
+void nr_map_expiry_init(struct nr_map_expiry *exq, int expiry_in_seconds)
+{
+	ZERO_STRUCT(exq);
+	exq->expiry_in_seconds = expiry_in_seconds;
+	INIT_LLIST_HEAD(&exq->mappings);
+}
+
+void nr_map_expiry_add(struct nr_map_expiry *exq, struct nr_mapping *mapping,
+		       time_t now)
+{
+	mapping->expiry = now + exq->expiry_in_seconds;
+
+	/* Add/move to the tail to always sort by expiry, ascending. */
+	llist_del(&mapping->expiry_entry);
+	llist_add_tail(&mapping->expiry_entry, &exq->mappings);
+}
+
+int nr_map_expiry_tick(struct nr_map_expiry *exq, time_t now)
+{
+	int expired = 0;
+	struct nr_mapping *m;
+	struct nr_mapping *n;
+	llist_for_each_entry_safe(m, n, &exq->mappings, expiry_entry) {
+		if (m->expiry <= now) {
+			nr_mapping_del(m);
+			expired ++;
+		}
+		else {
+			/* The items are added sorted by expiry. So when we hit
+			 * an unexpired entry, only more unexpired ones will
+			 * follow. */
+			break;
+		}
+	}
+	return expired;
+}
+
+
+/* gtphub */
+
+/* Remove a gtphub_peer from its list and free it. */
+static void gtphub_peer_del(struct gtphub_peer *peer);
+
+/* From the information in the gtp_packet_desc, return the address of a GGSN.
+ * Return -1 on error. */
+static struct gtphub_peer *gtphub_resolve_ggsn(struct gtphub *hub,
+					       struct gtp_packet_desc *p,
+					       unsigned int port_idx);
+
+/* (wrapped by unit test) */
+int gtphub_resolve_ggsn_addr(struct gtphub *hub,
+			     struct osmo_sockaddr *result,
+			     struct gtp_packet_desc *p);
+
+/* comment: see at definition */
+static struct gtphub_peer *gtphub_peer_new(struct gtphub *hub,
+					   struct gtphub_bind bind[],
+					   unsigned int port_idx,
+					   const char *addr_str,
+					   uint16_t port,
+					   const char *other_addr_str,
+					   uint16_t other_port);
+
+static struct gtphub_peer *gtphub_peer_new_from_sockaddr(struct gtphub *hub,
+							 struct gtphub_bind bind[],
+							 unsigned int port_idx,
+							 const struct osmo_sockaddr *addr,
+							 int port_override);
+
+static void gtphub_zero(struct gtphub *hub)
+{
+	ZERO_STRUCT(hub);
+}
+
+static int gtphub_sock_init(struct osmo_fd *ofd,
+			    const struct gtphub_cfg_addr *addr,
+			    osmo_fd_cb_t cb,
+			    void *data,
+			    int ofd_id)
+{
+	if (!addr->addr_str) {
+		LOGERR("Cannot bind: empty address.\n");
+		return -1;
+	}
+	if (!addr->port) {
+		LOGERR("Cannot bind: zero port not permitted.\n");
+		return -1;
+	}
+
+	ofd->when = BSC_FD_READ;
+	ofd->cb = cb;
+	ofd->data = data;
+	ofd->priv_nr = ofd_id;
+
+	int rc;
+	rc = osmo_sock_init_ofd(ofd,
+				AF_UNSPEC, SOCK_DGRAM, IPPROTO_UDP,
+				addr->addr_str, addr->port,
+				OSMO_SOCK_F_BIND);
+	if (rc < 1) {
+		LOGERR("Cannot bind to %s port %d (rc %d)\n",
+		       addr->addr_str, (int)addr->port, rc);
+		return -1;
+	}
+
+	return 0;
+}
+
+static void gtphub_gtp_bind_init(struct gtphub_bind *b)
+{
+	ZERO_STRUCT(b);
+
+	nr_pool_init(&b->tei_pool);
+	INIT_LLIST_HEAD(&b->peers);
+}
+
+static int gtphub_gtp_bind_start(struct gtphub_bind *b,
+				 const struct gtphub_cfg_bind *cfg,
+				 osmo_fd_cb_t cb, void *cb_data,
+				 unsigned int ofd_id)
+{
+	if (gtphub_sock_init(&b->ofd, &cfg->bind, cb, cb_data, ofd_id) != 0)
+		return -1;
+	return 0;
+}
+
+/* Recv datagram from from->fd, optionally write sender's address to *from_addr.
+ * Return the number of bytes read, zero on error. */
+static int gtphub_read(const struct osmo_fd *from,
+		       struct osmo_sockaddr *from_addr,
+		       uint8_t *buf, size_t buf_len)
+{
+	/* recvfrom requires the available length to be set in *from_addr_len. */
+	if (from_addr)
+		from_addr->l = sizeof(from_addr->a);
+
+	errno = 0;
+	ssize_t received = recvfrom(from->fd, buf, buf_len, 0,
+				    (struct sockaddr*)&from_addr->a, &from_addr->l);
+	/* TODO use recvmsg and get a MSG_TRUNC flag to make sure the message
+	 * is not truncated. Then maybe reduce buf's size. */
+
+	if (received <= 0) {
+		if (errno != EAGAIN)
+			LOGERR("error: %s\n", strerror(errno));
+		return 0;
+	}
+
+	if (from_addr) {
+		LOG("from %s\n", osmo_sockaddr_to_str(from_addr));
+	}
+
+	if (received <= 0) {
+		LOGERR("error: %s\n", strerror(errno));
+		return 0;
+	}
+
+	LOG("Received %d\n%s\n", (int)received, osmo_hexdump(buf, received));
+	return received;
+}
+
+inline void gtphub_peer_ref_count_inc(struct gtphub_peer *p)
+{
+	OSMO_ASSERT(p->ref_count < UINT_MAX);
+	p->ref_count++;
+}
+
+inline void gtphub_peer_ref_count_dec(struct gtphub_peer *p)
+{
+	OSMO_ASSERT(p->ref_count > 0);
+	p->ref_count--;
+}
+
+inline uint16_t get_seq(struct gtp_packet_desc *p)
+{
+	OSMO_ASSERT(p->version == 1);
+	return ntoh16(p->data->gtp1l.h.seq);
+}
+
+inline void set_seq(struct gtp_packet_desc *p, uint16_t seq)
+{
+	OSMO_ASSERT(p->version == 1);
+	p->data->gtp1l.h.seq = hton16(seq);
+}
+
+inline uint32_t get_tei(struct gtp_packet_desc *p)
+{
+	OSMO_ASSERT(p->version == 1);
+	return ntoh32(p->data->gtp1l.h.tei);
+}
+
+inline void set_tei(struct gtp_packet_desc *p, uint32_t tei)
+{
+	OSMO_ASSERT(p->version == 1);
+	p->data->gtp1l.h.tei = hton32(tei);
+}
+
+static void gtphub_mapping_del_cb(struct nr_mapping *nrm);
+
+static struct nr_mapping *gtphub_mapping_new()
+{
+	struct nr_mapping *nrm;
+	nrm = talloc_zero(osmo_gtphub_ctx, struct nr_mapping);
+	OSMO_ASSERT(nrm);
+
+	nr_mapping_init(nrm);
+	nrm->del_cb = gtphub_mapping_del_cb;
+	return nrm;
+}
+
+static void gtphub_mapping_del_cb(struct nr_mapping *nrm)
+{
+	struct gtphub_peer *from = nrm->origin;
+	OSMO_ASSERT(from);
+	LOG("expired: %d: nr mapping from %s: %d->%d\n",
+	    (int)nrm->expiry,
+	    osmo_sockaddr_to_str(&from->addr),
+	    (int)nrm->orig, (int)nrm->repl);
+
+	gtphub_peer_ref_count_dec(from);
+	talloc_free(nrm);
+}
+
+static struct nr_mapping *gtphub_mapping_have(struct nr_map *map,
+					      struct gtphub_peer *from,
+					      uint16_t orig_nr)
+{
+	struct nr_mapping *nrm;
+
+	nrm = nr_map_get(map, from, orig_nr);
+
+	if (!nrm) {
+		nrm = gtphub_mapping_new();
+		nrm->orig = orig_nr;
+		nrm->origin = from;
+		nr_map_add(map, nrm, gtphub_now());
+		gtphub_peer_ref_count_inc(from);
+		LOG("peer %p: MAP %d --> %d\n", from, (int)(nrm->orig), (int)(nrm->repl));
+	}
+	else {
+		/* restart expiry timeout */
+		nr_map_expiry_add(map->expiry, nrm, gtphub_now());
+	}
+
+	OSMO_ASSERT(nrm);
+	return nrm;
+}
+
+static int gtphub_map_seq(struct gtp_packet_desc *p,
+			  struct gtphub_peer *from_peer, struct gtphub_peer *to_peer)
+{
+	/* Store a mapping in to_peer's map, so when we later receive a GTP
+	 * packet back from to_peer, the seq nr can be unmapped back to its
+	 * origin (from_peer here). */
+	struct nr_mapping *nrm;
+	nrm = gtphub_mapping_have(&to_peer->seq_map, from_peer, get_seq(p));
+
+	/* Change the GTP packet to yield the new, mapped seq nr */
+	set_seq(p, nrm->repl);
+
+	return 0;
+}
+
+static struct gtphub_peer *gtphub_unmap_seq(struct gtp_packet_desc *p,
+					    struct gtphub_peer *responding_peer)
+{
+	OSMO_ASSERT(p->version == 1);
+	struct nr_mapping *nrm = nr_map_get_inv(&responding_peer->seq_map,
+						get_seq(p));
+	if (!nrm)
+		return NULL;
+	LOG("peer %p: UNMAP %d <-- %d\n", nrm->origin, (int)(nrm->orig), (int)(nrm->repl));
+	set_seq(p, nrm->orig);
+	return nrm->origin;
+}
+
+static void gtphub_check_restart_counter(struct gtphub *hub,
+					 struct gtp_packet_desc *p,
+					 struct gtphub_peer *from)
+{
+	/* TODO */
+	/* If the peer is sending a Recovery IE (7.7.11) with a restart counter
+	 * that doesn't match the peer's previously sent restart counter, clear
+	 * that peer and cancel PDP contexts. */
+}
+
+static void gtphub_map_restart_counter(struct gtphub *hub,
+				       struct gtp_packet_desc *p,
+				       struct gtphub_peer *from,
+				       struct gtphub_peer *to)
+{
+	/* TODO */
+}
+
+/* gtphub_map_ie_teis() and gtphub_unmap_header_tei():
+ *
+ * TEI mapping must happen symmetrically. An SGSN contacts gtphub instead of N
+ * GGSNs, and a GGSN replies to gtphub for N SGSNs. From either end, TEIs may
+ * collide: two GGSNs picking the same TEIs, or two SGSNs picking the same
+ * TEIs. Since the opposite side sees the sender address being gtphub's
+ * address, TEIs among the SGSNs, and among the GGSNs, must not overlap. If a
+ * peer sends a TEI already sent before from a peer of the same side, gtphub
+ * replaces it with a TEI not yet seen from that side and remembers the
+ * mapping.
+ *
+ * Consider two SGSNs A and B contacting two GGSNs C and D thru gtphub.
+ *
+ * A: Create PDP Ctx, I have TEI 1.
+ *    --->   gtphub: A has TEI 1, sending 1 for C.
+ *              --->   C: gtphub has TEI 1.
+ *      	<---   C: Reponse to TEI 1: I have TEI 11.
+ *    <---   gtphub: ok, telling A: 11.
+ * A: gtphub's first TEI is 11.                                         (1)
+ *
+ * B: Create PDP Ctx, I have TEIs 1.
+ *    --->   gtphub: 1 already taken for C, sending 2 for B. (map)
+ *              --->   C: gtphub also has 2.
+ *      	<---   C: Reponse to TEI 2: I have TEI 12.
+ *    <---   gtphub: ok, TEI 2 is actually B with TEI 1. (unmap)
+ * B: gtphub's first TEI is 12, as far as I can tell.
+ *
+ * Now the second GGSN comes into play:
+ *
+ * A: Create PDP Ctx, I have TEI 2.
+ *    --->   gtphub: A also has TEI 2, but for D, sending 1.            (2)
+ *              --->   D: gtphub has 1.
+ *      	<---   D: Reponse to TEI 1: I have TEI 11.
+ *    <---   gtphub: from D, 1 is A. 11 already taken by C, sending 13. (3)
+ * A: gtphub also has TEI 13.                                           (4)
+ *
+ * And some messages routed through:
+ *
+ * A: message to TEI 11, see (1).
+ *    --->   gtphub: ok, telling C with TEI 11.
+ *              --->   C: I see, 11 means reply with 1.
+ *      	<---   C: Response to TEI 1
+ *    <---   gtphub: 1 from C is actually for A with TEI 1.
+ * A: ah, my TEI 1, thanks!
+ *
+ * A: message to TEI 13, see (4).
+ *    --->   gtphub: ok, but not 13, D wanted TEI 11 instead, see (3).
+ *              --->   D: I see, 11 means reply with 1.
+ *      	<---   D: Response to TEI 1
+ *    <---   gtphub: 1 from D is actually for A with TEI 2, see (2).
+ * A: ah, my TEI 2, thanks!
+ *
+ * What if a GGSN initiates a request:
+ *
+ *              <---   D: Request to gtphub TEI 1
+ *    <---   gtphub: 1 from D is for A with 2, see (2).
+ * A: my TEI 2 means reply with 13.
+ *    --->   gtphub: 13 was D with 11, see (3).
+ *              --->   D: 11 from gtphub: a reply to my request for TEI 1.
+ *
+ * Note that usually, it's the sequence numbers that route a response back to
+ * the requesting peer. Nevertheless, the TEI mappings must be carried out to
+ * replace the TEIs in the GTP packet that is relayed.
+ *
+ * Also note: the TEI in the GTP header is "reversed" from the TEI in the IEs:
+ * the TEI in the header is used to send something *to* a peer, while the TEI
+ * in e.g. a Create PDP Context Request's IE is for routing messages *back*
+ * later. */
+
+static int gtphub_unmap_header_tei(struct gtphub_peer **to_peer_p,
+				   struct gtphub *hub,
+				   struct gtp_packet_desc *p,
+				   struct gtphub_peer *from_peer)
+{
+	OSMO_ASSERT(p->version == 1);
+
+	struct nr_mapping *nrm;
+	uint32_t tei;
+
+	*to_peer_p = NULL;
+
+	/* If the header's TEI is zero, no PDP context has been established
+	 * yet. If nonzero, a mapping should actually already exist for this
+	 * TEI, since it must have been announced in a PDP context creation. */
+	tei = get_tei(p);
+
+	if (!tei)
+		return 0;
+
+	/* to_peer has previously announced a TEI, which was stored and
+	 * mapped in from_peer's tei_map. */
+	nrm = nr_map_get_inv(&from_peer->tei_map, tei);
+	if (!nrm) {
+		LOGERR("Received unknown TEI %" PRIu32 " from %s\n",
+		       tei, osmo_sockaddr_to_str(&from_peer->addr));
+		return -1;
+	}
+
+	struct gtphub_peer *to_peer = nrm->origin;
+	uint32_t unmapped_tei = nrm->orig;
+	set_tei(p, unmapped_tei);
+
+	char buf[256];
+	LOG("Unmapped TEI coming from %p %s: %d -> %d (to %s)\n",
+	    from_peer, osmo_sockaddr_to_str(&from_peer->addr), tei,
+	    unmapped_tei, osmo_sockaddr_to_strb(&to_peer->addr, buf, sizeof(buf)));
+
+	*to_peer_p = to_peer;
+	return 0;
+}
+
+/* In a packet coming from from_peer, find TEI IE with type number ie_type.  If
+ * it does not exist yet, create a mapping for the TEI in to_peer's map.  A
+ * message coming from to_peer can then be addressed to the (new) mapped TEI,
+ * and will trace back to from_peer and the TEI in this p's IE.
+ *
+ * Also replace the TEI with a mapped TEI in the GTP packet's data buffer. The
+ * buffer can then be sent on to to_peer. */
+static int gtphub_map_tei_ie(struct gtp_packet_desc *p,
+			     uint8_t ie_type, int mandatory,
+			     struct gtphub_peer *from_peer,
+			     struct gtphub_peer *to_peer,
+			     unsigned int port_idx)
+{
+	int ie_idx;
+	uint32_t tei;
+	uint32_t mapped_tei;
+
+	OSMO_ASSERT(from_peer);
+	OSMO_ASSERT(to_peer);
+
+	from_peer = from_peer->association[port_idx];
+	to_peer = to_peer->association[port_idx];
+
+	if (!(from_peer && to_peer)) {
+		LOGERR("Missing peer in %s plane /"
+		       " missing Create PDP Context Request\n",
+		       gtphub_port_idx_names[port_idx]);
+		return -1;
+	}
+
+	ie_idx = gtpie_getie(p->ie, ie_type, 0);
+	if (ie_idx < 0) {
+		if (! mandatory)
+			return 0;
+
+		LOGERR("Create PDP Context Request: Invalid: missing IE %d\n", ie_type);
+		return -1;
+	}
+	tei = ntoh32(p->ie[ie_idx]->tv4.v);
+	/* When to_peer later sends the mapped tei for this tei, we
+	 * want to obtain this tei and from_peer. */
+	struct nr_mapping *nrm;
+	nrm = gtphub_mapping_have(&to_peer->tei_map,
+				  from_peer, tei);
+
+	mapped_tei = nrm->repl;
+	p->ie[ie_idx]->tv4.v = hton32(mapped_tei);
+
+	char buf[256];
+	LOG("Storing new %s TEI in peer %p %s's map: (from %s, TEI %d) -> TEI %d\n",
+	    gtphub_port_idx_names[port_idx],
+	    to_peer,
+	    osmo_sockaddr_to_str(&to_peer->addr),
+	    osmo_sockaddr_to_strb(&from_peer->addr, buf, sizeof(buf)),
+	    tei, mapped_tei);
+
+	return 0;
+}
+
+/* The responding peer does not know that it is going to be the responding peer
+ * yet, but it must already be resolved and initialized. The GTP packet is
+ * coming from requesting_peer.*/
+static int gtphub_handle_create_pdp_req(struct gtphub *hub,
+					struct gtp_packet_desc *p,
+					struct gtphub_peer *requesting_peer,
+					struct gtphub_peer *responding_peer)
+{
+	/* This is a Create PDP Context Request. Expecting this
+	 * to come from the SGSN side. */
+	/* TODO enforce? */
+	int rc;
+	
+	rc = gtphub_map_tei_ie(p, GTPIE_TEI_DI, 1,
+			       requesting_peer, responding_peer,
+			       GTPH_PORT_USER);
+	if (rc < 0)
+		return rc;
+
+	rc = gtphub_map_tei_ie(p, GTPIE_TEI_C, 1,
+			       requesting_peer, responding_peer,
+			       GTPH_PORT_CTRL);
+	return rc;
+}
+
+/* The GTP packet is coming from responding_peer; "responding peer" in the
+ * sense of the Create PDP Context messages. requesting_peer will have been
+ * figured out from sequence number and/or header TEI. */
+static int gtphub_handle_create_pdp_resp(struct gtphub *hub,
+					 struct gtp_packet_desc *p,
+					 struct gtphub_peer *responding_peer,
+					 struct gtphub_peer *requesting_peer)
+{
+	/* This is a Create PDP Context Response. Expecting this
+	 * to come from the GGSN side. */
+	/* TODO enforce? */
+	int rc;
+	
+	rc = gtphub_map_tei_ie(p, GTPIE_TEI_DI, 0,
+			       responding_peer, requesting_peer,
+			       GTPH_PORT_USER);
+	if (rc < 0)
+		return rc;
+
+	rc = gtphub_map_tei_ie(p, GTPIE_TEI_C, 0,
+			       responding_peer, requesting_peer,
+			       GTPH_PORT_CTRL);
+	return rc;
+}
+
+static int gtphub_map_ie_teis(struct gtphub *hub,
+			      struct gtp_packet_desc *p,
+			      struct gtphub_peer *from_peer,
+			      struct gtphub_peer *to_peer)
+{
+	OSMO_ASSERT(p->version == 1);
+	struct gtp1_header_long *h = &p->data->gtp1l.h;
+	uint8_t type = ntoh8(h->type);
+
+	switch (type) {
+	case GTP_CREATE_PDP_REQ:
+		gtphub_handle_create_pdp_req(hub, p, from_peer, to_peer);
+		break;
+
+	case GTP_CREATE_PDP_RSP:
+		gtphub_handle_create_pdp_resp(hub, p, from_peer, to_peer);
+		break;
+
+	default:
+		break;
+	}
+	return 0;
+}
+
+static void gtphub_replace_addresses(struct gtphub *hub,
+				     struct gtp_packet_desc *p,
+				     struct gtphub_peer *from,
+				     struct gtphub_bind *from_bind,
+				     struct gtphub_peer *to,
+				     struct gtphub_bind *to_bind)
+{
+	/* TODO */
+}
+
+static int gtphub_write(struct osmo_fd *to,
+			struct osmo_sockaddr *to_addr,
+			uint8_t *buf, size_t buf_len)
+{
+	errno = 0;
+	ssize_t sent = sendto(to->fd, buf, buf_len, 0,
+			      (struct sockaddr*)&to_addr->a, to_addr->l);
+
+	if (to_addr) {
+		LOG("to %s\n", osmo_sockaddr_to_str(to_addr));
+	}
+
+	if (sent == -1) {
+		LOGERR("error: %s\n", strerror(errno));
+		return -EINVAL;
+	}
+
+	if (sent != buf_len)
+		LOGERR("sent(%d) != data_len(%d)\n", (int)sent, (int)buf_len);
+	else
+		LOG("Sent %d\n%s\n", (int)sent, osmo_hexdump(buf, sent));
+
+	return 0;
+}
+
+int gtphub_from_ggsns_handle_buf(struct gtphub *hub,
+				 unsigned int port_idx,
+				 const struct osmo_sockaddr *from_addr,
+				 uint8_t *buf,
+				 size_t received,
+				 struct osmo_fd **to_ofd,
+				 struct osmo_sockaddr **to_addr);
+
+static struct gtphub_peer *gtphub_peer_find(const struct gtphub_bind *bind,
+					    const struct osmo_sockaddr *addr);
+
+static int from_ggsns_read_cb(struct osmo_fd *from_ggsns_ofd, unsigned int what)
+{
+	unsigned int port_idx = from_ggsns_ofd->priv_nr;
+	OSMO_ASSERT(port_idx < GTPH_PORT_N);
+	LOG("\n\n=== reading from GGSN (%s)\n", gtphub_port_idx_names[port_idx]);
+	if (!(what & BSC_FD_READ))
+		return 0;
+
+	struct gtphub *hub = from_ggsns_ofd->data;
+
+	static uint8_t buf[4096];
+	struct osmo_sockaddr from_addr;
+	struct osmo_sockaddr *to_addr;
+	struct osmo_fd *to_ofd;
+	size_t len;
+	
+	len = gtphub_read(from_ggsns_ofd, &from_addr, buf, sizeof(buf));
+	if (len < 1)
+		return 0;
+
+	len = gtphub_from_ggsns_handle_buf(hub, port_idx, &from_addr, buf, len,
+					   &to_ofd, &to_addr);
+	if (len < 1)
+		return 0;
+
+	return gtphub_write(to_ofd, to_addr, buf, len);
+}
+
+static int gtphub_unmap(struct gtphub *hub,
+			struct gtp_packet_desc *p,
+			struct gtphub_peer *from,
+			struct gtphub_peer *to_proxy,
+			struct gtphub_peer **final_unmapped,
+			struct gtphub_peer **unmapped_from_seq,
+			struct gtphub_peer **unmapped_from_tei)
+{
+	/* Always (try to) unmap sequence and TEI numbers, which need to be
+	 * replaced in the packet. Either way, give precedence to the proxy, if
+	 * configured. */
+
+	struct gtphub_peer *from_seq = NULL;
+	struct gtphub_peer *from_tei = NULL;
+	struct gtphub_peer *unmapped = NULL;
+
+	if (unmapped_from_seq)
+		*unmapped_from_seq = from_seq;
+	if (unmapped_from_tei)
+		*unmapped_from_tei = from_tei;
+	if (final_unmapped)
+		*final_unmapped = unmapped;
+	
+	from_seq = gtphub_unmap_seq(p, from);
+
+	if (gtphub_unmap_header_tei(&from_tei, hub, p, from) < 0)
+		return -1;
+
+	if (from_seq && from_tei && (from_seq != from_tei)) {
+		char b0[256];
+		char b1[256];
+		LOGERR("Seq unmap and TEI unmap yield two different peers. Using seq unmap."
+		       "(from %s %s: seq %d yields %s, tei %u yields %s)\n",
+		       gtphub_port_idx_names[p->port_idx],
+		       osmo_sockaddr_to_str(&from->addr),
+		       (int)get_seq(p),
+		       osmo_sockaddr_to_strb(&from_seq->addr, b0, sizeof(b0)),
+		       (int)get_tei(p),
+		       osmo_sockaddr_to_strb(&from_tei->addr, b1, sizeof(b1))
+		       );
+	}
+	unmapped = (from_seq? from_seq : from_tei);
+
+	if (unmapped && to_proxy && (unmapped != to_proxy)) {
+		char buf[256];
+		LOGERR("Unmap yields a different peer than the configured proxy. Using proxy."
+		       " unmapped: %s  proxy: %s\n",
+		       osmo_sockaddr_to_str(&unmapped->addr),
+		       osmo_sockaddr_to_strb(&to_proxy->addr, buf, sizeof(buf))
+		       );
+	}
+	unmapped = (to_proxy? to_proxy : unmapped);
+
+	if (!unmapped) {
+		/* Return no error, but returned pointers are all NULL. */
+		return 0;
+	}
+
+	LOG("from seq %p; from tei %p; unmapped => %p\n",
+	    from_seq, from_tei, unmapped);
+
+	if (unmapped_from_seq)
+		*unmapped_from_seq = from_seq;
+	if (unmapped_from_tei)
+		*unmapped_from_tei = from_tei;
+	if (final_unmapped)
+		*final_unmapped = unmapped;
+	return 0;
+}
+
+/* Parse buffer as GTP packet, replace elements in-place and return the ofd and
+ * address to forward to. Return the number of bytes to forward, 0 or less on
+ * failure. Return the fd and address to forward to in to_ofd and to_addr. */
+int gtphub_from_ggsns_handle_buf(struct gtphub *hub,
+				 unsigned int port_idx,
+				 const struct osmo_sockaddr *from_addr,
+				 uint8_t *buf,
+				 size_t received,
+				 struct osmo_fd **to_ofd,
+				 struct osmo_sockaddr **to_addr)
+{
+	LOG("<- rx from GGSN %s\n", osmo_sockaddr_to_str(from_addr));
+
+	static struct gtp_packet_desc p;
+	gtp_decode(buf, received, port_idx, &p);
+
+	if (p.rc <= 0)
+		return -1;
+
+	/* If a GGSN proxy is configured, check that it's indeed that proxy
+	 * talking to us. */
+	struct gtphub_peer *ggsn = hub->ggsn_proxy[port_idx];
+	if (ggsn && (osmo_sockaddr_cmp(&ggsn->addr, from_addr) != 0)) {
+		char buf[256];
+		LOGERR("Rejecting: GGSN proxy configured, but GTP packet"
+		       " received on GGSN bind is from another sender:"
+		       " proxy: %s  sender: %s\n", 
+		       osmo_sockaddr_to_str(&ggsn->addr),
+		       osmo_sockaddr_to_strb(from_addr, buf, sizeof(buf)));
+		return -1;
+	}
+
+	if (!ggsn) {
+		/* If any PDP context has been created, we already have an
+		 * entry for this GGSN. If we don't have an entry, the GGSN has
+		 * nothing to tell us about. */
+		ggsn = gtphub_peer_find(&hub->to_ggsns[port_idx], from_addr);
+		LOG("Found peer %p for %s\n", ggsn, osmo_sockaddr_to_str(from_addr));
+	}
+
+	if (!ggsn) {
+		LOGERR("no ggsn\n");
+		return -1;
+	}
+
+	struct gtphub_peer *sgsn_from_seq;
+	struct gtphub_peer *sgsn;
+	if (gtphub_unmap(hub, &p, ggsn,
+			 hub->sgsn_proxy[port_idx],
+			 &sgsn, &sgsn_from_seq, NULL)
+	    != 0) {
+		return -1;
+	}
+
+	if (!sgsn) {
+		LOGERR("No SGSN to send to. Dropping packet.\n");
+		return -1;
+	}
+
+	gtphub_check_restart_counter(hub, &p, ggsn);
+	gtphub_map_restart_counter(hub, &p, ggsn, sgsn);
+	gtphub_replace_addresses(hub, &p,
+				 ggsn, &hub->to_ggsns[port_idx],
+				 sgsn, &hub->to_sgsns[port_idx]);
+
+	/* If the GGSN is replying to an SGSN request, the sequence nr has
+	 * already been unmapped above (sgsn_from_seq != NULL), and we need not
+	 * create a new mapping. */
+	if (!sgsn_from_seq)
+		gtphub_map_seq(&p, ggsn, sgsn);
+
+	/* If IEs announce new TEIs, map those. */
+	if (gtphub_map_ie_teis(hub, &p, ggsn, sgsn) < 0) {
+		LOGERR("Dropping invalid packet\n");
+		return -1;
+	}
+
+	*to_ofd = &hub->to_sgsns[port_idx].ofd;
+	*to_addr = &sgsn->addr;
+
+	return received;
+}
+
+int gtphub_from_sgsns_handle_buf(struct gtphub *hub,
+				 unsigned int port_idx,
+				 const struct osmo_sockaddr *from_addr,
+				 uint8_t *buf,
+				 size_t received,
+				 struct osmo_fd **to_ofd,
+				 struct osmo_sockaddr **to_addr);
+
+static int from_sgsns_read_cb(struct osmo_fd *from_sgsns_ofd, unsigned int what)
+{
+	unsigned int port_idx = from_sgsns_ofd->priv_nr;
+	OSMO_ASSERT(port_idx < GTPH_PORT_N);
+	LOG("\n\n=== reading from SGSN (%s)\n", gtphub_port_idx_names[port_idx]);
+
+	if (!(what & BSC_FD_READ))
+		return 0;
+
+	struct gtphub *hub = from_sgsns_ofd->data;
+
+	static uint8_t buf[4096];
+	struct osmo_sockaddr from_addr;
+	struct osmo_sockaddr *to_addr;
+	struct osmo_fd *to_ofd;
+	size_t len;
+	
+	len = gtphub_read(from_sgsns_ofd, &from_addr, buf, sizeof(buf));
+	if (len < 1)
+		return 0;
+
+	len = gtphub_from_sgsns_handle_buf(hub, port_idx, &from_addr, buf, len,
+					   &to_ofd, &to_addr);
+	if (len < 1)
+		return 0;
+
+	return gtphub_write(to_ofd, to_addr, buf, len);
+}
+
+/* Parse buffer as GTP packet, replace elements in-place and return the ofd and
+ * address to forward to. Return the number of bytes to forward, 0 or less on
+ * failure. Return the fd and address to forward to in to_ofd and to_addr. */
+int gtphub_from_sgsns_handle_buf(struct gtphub *hub,
+				 unsigned int port_idx,
+				 const struct osmo_sockaddr *from_addr,
+				 uint8_t *buf,
+				 size_t received,
+				 struct osmo_fd **to_ofd,
+				 struct osmo_sockaddr **to_addr)
+{
+	LOG("-> rx from SGSN %s\n", osmo_sockaddr_to_str(from_addr));
+
+	static struct gtp_packet_desc p;
+	gtp_decode(buf, received, port_idx, &p);
+
+	if (p.rc <= 0)
+		return -1;
+
+	/* If an SGSN proxy is configured, check that it's indeed that proxy
+	 * talking to us. */
+	struct gtphub_peer *sgsn = hub->sgsn_proxy[port_idx];
+	if (sgsn && (osmo_sockaddr_cmp(&sgsn->addr, from_addr) != 0)) {
+		char buf[256];
+		LOGERR("Rejecting: SGSN proxy configured, but GTP packet"
+		       " received on SGSN bind is from another sender: "
+		       "proxy: %s  sender: %s\n",
+		       osmo_sockaddr_to_str(from_addr),
+		       osmo_sockaddr_to_strb(&sgsn->addr, buf, sizeof(buf)));
+		return -1;
+	}
+
+	if (!sgsn) {
+		/* TODO this is a worthless hack to test things. Instead:
+		   sgsn = gtphub_sgsn_get(hub, ...); */
+		sgsn = llist_first(&hub->to_sgsns[port_idx].peers,
+				   struct gtphub_peer, entry);
+		if (!sgsn) {
+			sgsn = gtphub_peer_new_from_sockaddr(hub,
+							     hub->to_sgsns,
+							     port_idx,
+							     from_addr,
+							     -1);
+			if (!sgsn) {
+				LOG("Packet dropped by preliminary debug code.\n");
+				return -1;
+			}
+			LOG("Created peer %p\n", sgsn);
+		}
+
+		if (osmo_sockaddr_cmp(&sgsn->addr, from_addr) != 0) {
+			char buf[256];
+			LOG("SGSN changed its address from %s to %s\n",
+			    osmo_sockaddr_to_str(&sgsn->addr),
+			    osmo_sockaddr_to_strb(from_addr, buf, sizeof(buf)));
+			memcpy(&sgsn->addr, from_addr, sizeof(sgsn->addr));
+		}
+		LOG("Peer %p = %s\n", sgsn, osmo_sockaddr_to_str(&sgsn->addr));
+	}
+
+	struct gtphub_peer *ggsn_from_seq;
+	struct gtphub_peer *ggsn;
+	if (gtphub_unmap(hub, &p, sgsn,
+			 hub->ggsn_proxy[port_idx],
+			 &ggsn, &ggsn_from_seq, NULL)
+	    != 0) {
+		return -1;
+	}
+
+	/* See what our GGSN guess would be from the packet data per se. */
+	/* TODO maybe not do this always? */
+	struct gtphub_peer *ggsn_from_packet;
+	ggsn_from_packet = gtphub_resolve_ggsn(hub, &p, port_idx);
+
+	if (ggsn_from_packet && ggsn
+	    && (ggsn_from_packet != ggsn)) {
+		char buf[256];
+		LOGERR("GGSN implied from packet does not match unmapped"
+		       " GGSN, using unmapped GGSN:"
+		       " from packet: %s  unmapped: %s\n",
+		       osmo_sockaddr_to_str(&ggsn_from_packet->addr),
+		       osmo_sockaddr_to_strb(&ggsn_from_packet->addr,
+					     buf, sizeof(buf)));
+		/* TODO return -1; ? */
+	}
+
+	if (!ggsn)
+		ggsn = ggsn_from_packet;
+
+	if (!ggsn) {
+		LOGERR("No GGSN to send to. Dropping packet.\n");
+		return -1;
+	}
+
+	gtphub_check_restart_counter(hub, &p, sgsn);
+	gtphub_map_restart_counter(hub, &p, sgsn, ggsn);
+	gtphub_replace_addresses(hub, &p,
+				 sgsn, &hub->to_sgsns[port_idx],
+				 ggsn, &hub->to_ggsns[port_idx]);
+
+	/* If the SGSN is replying to a GGSN request, the sequence nr has
+	 * already been unmapped above (unmap_ggsn != NULL), and we need not
+	 * create a new outgoing sequence map. */
+	if (!ggsn_from_seq)
+		gtphub_map_seq(&p, sgsn, ggsn);
+
+	/* If IEs announce new TEIs, map those. */
+	if (gtphub_map_ie_teis(hub, &p, sgsn, ggsn) < 0) {
+		LOGERR("Dropping invalid packet\n");
+		return -1;
+	}
+
+	*to_ofd = &hub->to_ggsns[port_idx].ofd;
+	*to_addr = &ggsn->addr;
+
+	return received;
+}
+
+static void gtphub_gc_bind(struct gtphub *hub, struct gtphub_bind *b)
+{
+	struct gtphub_peer *p, *n;
+	llist_for_each_entry_safe(p, n, &b->peers, entry) {
+
+		if ((!p->ref_count)
+		    && nr_map_empty(&p->seq_map)) {
+
+			LOG("expired: peer %s\n",
+			    osmo_sockaddr_to_str(&p->addr));
+			gtphub_peer_del(p);
+		}
+	}
+}
+
+void gtphub_gc(struct gtphub *hub, time_t now)
+{
+	int expired;
+	expired = nr_map_expiry_tick(&hub->expire_seq_maps, now);
+	expired += nr_map_expiry_tick(&hub->expire_tei_maps, now);
+
+	/* ... */
+
+	if (expired) {
+		int i;
+		for (i = 0; i < GTPH_PORT_N; i++) {
+			gtphub_gc_bind(hub, &hub->to_sgsns[i]);
+			gtphub_gc_bind(hub, &hub->to_ggsns[i]);
+		}
+	}
+}
+
+static void gtphub_gc_cb(void *data)
+{
+	struct gtphub *hub = data;
+	gtphub_gc(hub, gtphub_now());
+	osmo_timer_schedule(&hub->gc_timer, GTPH_GC_TICK_SECONDS, 0);
+}
+
+static void gtphub_gc_start(struct gtphub *hub)
+{
+	hub->gc_timer.cb = gtphub_gc_cb;
+	hub->gc_timer.data = hub;
+
+	osmo_timer_schedule(&hub->gc_timer, GTPH_GC_TICK_SECONDS, 0);
+}
+
+/* called by unit tests */
+void gtphub_init(struct gtphub *hub)
+{
+	gtphub_zero(hub);
+
+	nr_map_expiry_init(&hub->expire_seq_maps, GTPH_SEQ_MAPPING_EXPIRY_SECS);
+	nr_map_expiry_init(&hub->expire_tei_maps, GTPH_TEI_MAPPING_EXPIRY_MINUTES * 60);
+
+	int port_idx;
+	for (port_idx = 0; port_idx < GTPH_PORT_N; port_idx++) {
+		gtphub_gtp_bind_init(&hub->to_ggsns[port_idx]);
+		gtphub_gtp_bind_init(&hub->to_sgsns[port_idx]);
+	}
+}
+
+int gtphub_start(struct gtphub *hub, struct gtphub_cfg *cfg)
+{
+	int rc;
+
+	gtphub_init(hub);
+
+	int port_idx;
+	for (port_idx = 0; port_idx < GTPH_PORT_N; port_idx++) {
+		rc = gtphub_gtp_bind_start(&hub->to_ggsns[port_idx],
+					   &cfg->to_ggsns[port_idx],
+					   from_ggsns_read_cb, hub, port_idx);
+		if (rc) {
+			LOGERR("Failed to bind for GGSNs (%s)\n",
+			       gtphub_port_idx_names[port_idx]);
+			return rc;
+		}
+
+		rc = gtphub_gtp_bind_start(&hub->to_sgsns[port_idx],
+					   &cfg->to_sgsns[port_idx],
+					   from_sgsns_read_cb, hub, port_idx);
+		if (rc) {
+			LOGERR("Failed to bind for SGSNs (%s)\n",
+			       gtphub_port_idx_names[port_idx]);
+			return rc;
+		}
+	}
+
+	/* SGSN proxy. Trigger only on the control port address. */
+	if (cfg->sgsn_proxy[GTPH_PORT_CTRL].addr_str) {
+		struct gtphub_cfg_addr *addr_c = &cfg->sgsn_proxy[GTPH_PORT_CTRL];
+		struct gtphub_cfg_addr *addr_u = &cfg->sgsn_proxy[GTPH_PORT_USER];
+
+		struct gtphub_peer *sgsn_c;
+		struct gtphub_peer *sgsn_u;
+		sgsn_c = gtphub_peer_new(hub, hub->to_sgsns, GTPH_PORT_CTRL,
+					 addr_c->addr_str, addr_c->port,
+					 addr_u->addr_str, addr_u->port);
+		sgsn_u = sgsn_c->association[GTPH_PORT_USER];
+
+		hub->sgsn_proxy[GTPH_PORT_CTRL] = sgsn_c;
+		hub->sgsn_proxy[GTPH_PORT_USER] = sgsn_u;
+
+		/* This is *the* proxy SGSN. Make sure it is never expired. */
+		gtphub_peer_ref_count_inc(sgsn_c);
+		gtphub_peer_ref_count_inc(sgsn_u);
+
+		LOG("Using SGSN %s proxy %s port %d\n",
+		    gtphub_port_idx_names[GTPH_PORT_CTRL],
+		    addr_c->addr_str,
+		    (int)addr_c->port);
+		LOG("Using SGSN %s proxy %s port %d\n",
+		    gtphub_port_idx_names[GTPH_PORT_USER],
+		    addr_u->addr_str,
+		    (int)addr_u->port);
+	}
+
+	/* GGSN proxy. Trigger only on the control port address. */
+	if (cfg->ggsn_proxy[GTPH_PORT_CTRL].addr_str) {
+		struct gtphub_cfg_addr *addr_c = &cfg->ggsn_proxy[GTPH_PORT_CTRL];
+		struct gtphub_cfg_addr *addr_u = &cfg->ggsn_proxy[GTPH_PORT_USER];
+
+		struct gtphub_peer *ggsn_c;
+		struct gtphub_peer *ggsn_u;
+		ggsn_c = gtphub_peer_new(hub, hub->to_ggsns, GTPH_PORT_CTRL,
+					 addr_c->addr_str, addr_c->port,
+					 addr_u->addr_str, addr_u->port);
+		ggsn_u = ggsn_c->association[GTPH_PORT_USER];
+
+		hub->ggsn_proxy[GTPH_PORT_CTRL] = ggsn_c;
+		hub->ggsn_proxy[GTPH_PORT_USER] = ggsn_u;
+
+		/* This is *the* proxy GGSN. Make sure it is never expired. */
+		gtphub_peer_ref_count_inc(ggsn_c);
+		gtphub_peer_ref_count_inc(ggsn_u);
+
+		LOG("Using GGSN %s proxy %s port %d\n",
+		    gtphub_port_idx_names[GTPH_PORT_CTRL],
+		    addr_c->addr_str,
+		    (int)addr_c->port);
+		LOG("Using GGSN %s proxy %s port %d\n",
+		    gtphub_port_idx_names[GTPH_PORT_USER],
+		    addr_u->addr_str,
+		    (int)addr_u->port);
+	}
+
+	gtphub_gc_start(hub);
+	return 0;
+}
+
+static struct gtphub_peer *_gtphub_peer_new(struct gtphub *hub,
+					    struct gtphub_bind bind[],
+					    unsigned int port_idx,
+					    const char *addr_str,
+					    uint16_t port)
+{
+	struct gtphub_peer *n = talloc_zero(osmo_gtphub_ctx, struct gtphub_peer);
+
+	nr_map_init(&n->tei_map, &bind[port_idx].tei_pool, &hub->expire_tei_maps);
+
+	nr_pool_init(&n->seq_pool);
+	nr_map_init(&n->seq_map, &n->seq_pool, &hub->expire_seq_maps);
+
+	/* TODO use something random to pick the initial sequence nr.
+	   0x6d31 produces the ASCII character sequence 'm1', currently used in
+	   gtphub_nc_test.sh. */
+	n->seq_pool.last_nr = 0x6d31 - 1;
+
+	llist_add(&n->entry, &bind[port_idx].peers);
+
+	if (!port) {
+		port = (port_idx == GTPH_PORT_USER)? GTP1U_PORT : GTP1C_PORT;
+	}
+
+	LOG("New peer: %s %s %d\n", gtphub_port_idx_names[port_idx], addr_str,
+	    (int)port);
+
+	if (osmo_sockaddr_init_udp(&n->addr, addr_str, port) != 0) {
+		LOGERR("Cannot resolve '%s port %d'\n",
+		       addr_str, (int)port);
+		talloc_free(n);
+		return NULL;
+	}
+
+	return n;
+}
+
+/* Create two new gtphub_peer instances added to bind[CTRL]->peers and
+ * bind[USER]->peers.
+ * Initialize:
+ * - point association[port_idx]es to the respective instances,
+ * - resolve the provided addresses,
+ * - set default port numbers for the respective planes, if a given port is 0.
+ * addr_str and port define the peer for the GTP plane indicated by port_idx.
+ * other_addr_str and other_port are used to initialize the peer on the
+ * respective other plane (if port_idx is GTPH_PORT_CTRL, other_addr_str and
+ * other_port are for the User plane). other_addr_str may be NULL, in which
+ * case addr_str is used for both planes. One or both ports may be 0, in which
+ * case the default port for that plane is used.  Return a pointer to the new
+ * instance in the <port_idx> plane, or NULL on error. */
+static struct gtphub_peer *gtphub_peer_new(struct gtphub *hub,
+					   struct gtphub_bind bind[],
+					   unsigned int port_idx,
+					   const char *addr_str,
+					   uint16_t port,
+					   const char *other_addr_str,
+					   uint16_t other_port)
+{
+	if (!other_addr_str)
+		other_addr_str = addr_str;
+
+	const char *addr_str_c;
+	const char *addr_str_u;
+	uint16_t port_c;
+	uint16_t port_u;
+	if (port_idx == GTPH_PORT_CTRL) {
+		addr_str_c = addr_str;
+		addr_str_u = other_addr_str;
+		port_c = port;
+		port_u = other_port;
+	}
+	else {
+		OSMO_ASSERT(port_idx == GTPH_PORT_USER);
+		addr_str_u = addr_str;
+		addr_str_c = other_addr_str;
+		port_u = port;
+		port_c = other_port;
+	}
+
+	struct gtphub_peer *c = _gtphub_peer_new(hub, bind, GTPH_PORT_CTRL,
+						 addr_str_c, port_c);
+	struct gtphub_peer *u = _gtphub_peer_new(hub, bind, GTPH_PORT_USER,
+						 addr_str_u, port_u);
+
+	c->association[GTPH_PORT_CTRL] = u->association[GTPH_PORT_CTRL] = c;
+	c->association[GTPH_PORT_USER] = u->association[GTPH_PORT_USER] = u;
+
+	return c->association[port_idx];
+}
+
+/* port_override: -1: use port from addr; 0: use plane's default port; >0: use
+ * this number as port. */
+static struct gtphub_peer *gtphub_peer_new_from_sockaddr(struct gtphub *hub,
+							 struct gtphub_bind bind[],
+							 unsigned int port_idx,
+							 const struct osmo_sockaddr *addr,
+							 int port_override)
+{
+	char addr_str[256];
+	char port_str[6];
+
+	if (osmo_sockaddr_to_strs(addr_str, sizeof(addr_str),
+				  port_str, sizeof(port_str),
+				  addr,
+				  (NI_NUMERICHOST | NI_NUMERICSERV))
+	    != 0) {
+		return NULL;
+	}
+
+	uint16_t port = port_override < 0? atoi(port_str) : port_override;
+
+	return gtphub_peer_new(hub, bind, port_idx, addr_str, port, NULL, 0);
+}
+
+static void gtphub_peer_del(struct gtphub_peer *peer)
+{
+	nr_map_clear(&peer->seq_map);
+	llist_del(&peer->entry);
+	talloc_free(peer);
+}
+
+static struct gtphub_peer *gtphub_peer_find(const struct gtphub_bind *bind,
+					    const struct osmo_sockaddr *addr)
+{
+	struct gtphub_peer *peer;
+	llist_for_each_entry(peer, &bind->peers, entry) {
+		if (osmo_sockaddr_cmp(addr, &peer->addr) == 0)
+			return peer;
+	}
+	return NULL;
+}
+
+static struct gtphub_peer *gtphub_have_ggsn(struct gtphub *hub,
+					    struct osmo_sockaddr *addr,
+					    unsigned int port_idx)
+{
+	struct gtphub_peer *peer;
+	peer = gtphub_peer_find(&hub->to_ggsns[port_idx], addr);
+	if (peer)
+		return peer;
+
+	/* Not found, create one. */
+	peer = gtphub_peer_new_from_sockaddr(hub, hub->to_ggsns, port_idx, addr, -1);
+	return peer;
+}
+
+static struct gtphub_peer *gtphub_resolve_ggsn(struct gtphub *hub,
+					       struct gtp_packet_desc *p,
+					       unsigned int port_idx)
+{
+	int rc;
+
+	struct osmo_sockaddr addr;
+
+	rc = gtphub_resolve_ggsn_addr(hub, &addr, p);
+	if (rc < 0)
+		return NULL;
+
+	return gtphub_have_ggsn(hub, &addr, port_idx);
+}
+
+
+/* TODO move to osmocom/core/socket.c ? */
+/* The caller is required to call freeaddrinfo(*result), iff zero is returned. */
+/* use this in osmo_sock_init() to remove dup. */
+static int _osmo_getaddrinfo(struct addrinfo **result,
+			     uint16_t family, uint16_t type, uint8_t proto,
+			     const char *host, uint16_t port)
+{
+	struct addrinfo hints;
+	char portbuf[16];
+
+	sprintf(portbuf, "%u", port);
+	memset(&hints, '\0', sizeof(struct addrinfo));
+	hints.ai_family = family;
+	if (type == SOCK_RAW) {
+		/* Workaround for glibc, that returns EAI_SERVICE (-8) if
+		 * SOCK_RAW and IPPROTO_GRE is used.
+		 */
+		hints.ai_socktype = SOCK_DGRAM;
+		hints.ai_protocol = IPPROTO_UDP;
+	} else {
+		hints.ai_socktype = type;
+		hints.ai_protocol = proto;
+	}
+
+	return getaddrinfo(host, portbuf, &hints, result);
+}
+
+/* TODO move to osmocom/core/socket.c ? */
+int osmo_sockaddr_init(struct osmo_sockaddr *addr,
+		       uint16_t family, uint16_t type, uint8_t proto,
+		       const char *host, uint16_t port)
+{
+	struct addrinfo *res;
+	int rc;
+	rc = _osmo_getaddrinfo(&res, family, type, proto, host, port);
+
+	if (rc != 0) {
+		LOGERR("getaddrinfo returned error %d\n", (int)rc);
+		return -EINVAL;
+	}
+
+	OSMO_ASSERT(res->ai_addrlen <= sizeof(addr->a));
+	memcpy(&addr->a, res->ai_addr, res->ai_addrlen);
+	addr->l = res->ai_addrlen;
+	freeaddrinfo(res);
+
+	return 0;
+}
+
+int osmo_sockaddr_to_strs(char *addr_str, size_t addr_str_len,
+			  char *port_str, size_t port_str_len,
+			  const struct osmo_sockaddr *addr,
+			  int flags)
+{
+       int rc;
+
+       if ((addr->l < 1) || (addr->l > sizeof(addr->a))) {
+	       LOGP(DGTPHUB, LOGL_ERROR, "Invalid address size: %d\n", addr->l);
+	       return -1;
+       }
+
+       if (addr->l > sizeof(addr->a)) {
+	       LOGP(DGTPHUB, LOGL_ERROR, "Invalid address: too long: %d\n", addr->l);
+	       return -1;
+       }
+
+       rc = getnameinfo((struct sockaddr*)&addr->a, addr->l,
+			addr_str, addr_str_len,
+			port_str, port_str_len,
+			flags);
+
+       if (rc)
+	       LOGP(DGTPHUB, LOGL_ERROR, "Invalid address: %s: %s\n", gai_strerror(rc),
+		    osmo_hexdump((uint8_t*)&addr->a, addr->l));
+
+       return rc;
+}
+
+const char *osmo_sockaddr_to_strb(const struct osmo_sockaddr *addr,
+				  char *buf, size_t buf_len)
+{
+	const int portbuf_len = 6;
+	OSMO_ASSERT(buf_len > portbuf_len);
+	char *portbuf = buf + buf_len - portbuf_len;
+	buf_len -= portbuf_len;
+	if (osmo_sockaddr_to_strs(buf, buf_len,
+				  portbuf, portbuf_len,
+				  addr,
+				  NI_NUMERICHOST | NI_NUMERICSERV))
+		return NULL;
+
+	char *pos = buf + strnlen(buf, buf_len-1);
+	size_t len = buf_len - (pos - buf);
+
+	snprintf(pos, len, " port %s", portbuf);
+	buf[buf_len-1] = '\0';
+
+	return buf;
+}
+
+const char *osmo_sockaddr_to_str(const struct osmo_sockaddr *addr)
+{
+	static char buf[256];
+	const char *result = osmo_sockaddr_to_strb(addr, buf, sizeof(buf));
+	if (! result)
+		return "(invalid)";
+	return result;
+}
+
+int osmo_sockaddr_cmp(const struct osmo_sockaddr *a, const struct osmo_sockaddr *b)
+{
+	if (a->l != b->l) {
+		/* Lengths are not the same, but determine the order. Will
+		 * anyone ever sort a list by osmo_sockaddr though...? */
+		int cmp = memcmp(&a->a, &b->a, (a->l < b->l)? a->l : b->l);
+		if (cmp == 0) {
+			if (a->l < b->l)
+				return -1;
+			else
+				return 1;
+		}
+		return cmp;
+	}
+	return memcmp(&a->a, &b->a, a->l);
+}
+
+void osmo_sockaddr_copy(struct osmo_sockaddr *dst, const struct osmo_sockaddr *src)
+{
+	OSMO_ASSERT(src->l <= sizeof(dst->a));
+	memcpy(&dst->a, &src->a, src->l);
+	dst->l = src->l;
+}
diff --git a/openbsc/src/gprs/gtphub_main.c b/openbsc/src/gprs/gtphub_main.c
new file mode 100644
index 0000000..84c7969
--- /dev/null
+++ b/openbsc/src/gprs/gtphub_main.c
@@ -0,0 +1,283 @@ 
+/* GTP Hub main program */
+
+/* (C) 2015 by sysmocom s.f.m.c. GmbH <info@sysmocom.de>
+ * All Rights Reserved
+ *
+ * Author: Neels Hofmeyr
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <unistd.h>
+#include <signal.h>
+#include <string.h>
+#include <errno.h>
+
+#define _GNU_SOURCE
+#include <getopt.h>
+
+#include <osmocom/core/signal.h>
+#include <osmocom/core/application.h>
+#include <osmocom/core/logging.h>
+#include <osmocom/core/utils.h>
+#include <osmocom/core/rate_ctr.h>
+
+#include <osmocom/vty/logging.h>
+#include <osmocom/vty/telnet_interface.h>
+
+#include <openbsc/debug.h>
+#include <openbsc/gtphub.h>
+#include <openbsc/vty.h>
+
+#include "../../bscconfig.h"
+
+#define LOGERR(fmt, args...) \
+	LOGP(DGTPHUB, LOGL_ERROR, fmt, ##args)
+
+#define LOG(fmt, args...) \
+	LOGP(DGTPHUB, LOGL_NOTICE, fmt, ##args)
+
+#ifndef OSMO_VTY_PORT_GTPHUB
+/* should come from libosmocore */
+#define OSMO_VTY_PORT_GTPHUB	4253
+#endif
+
+extern void *osmo_gtphub_ctx;
+
+
+const char *gtphub_copyright =
+	"Copyright (C) 2015 sysmocom s.f.m.c GmbH <info@sysmocom.de>\r\n"
+	"License AGPLv3+: GNU AGPL version 2 or later <http://gnu.org/licenses/agpl-3.0.html>\r\n"
+	"This is free software: you are free to change and redistribute it.\r\n"
+	"There is NO WARRANTY, to the extent permitted by law.\r\n";
+
+static struct log_info_cat gtphub_categories[] = {
+	[DGTPHUB] = {
+		.name = "DGTPHUB",
+		.description = "GTP Hub",
+		.color = "\033[1;33m",
+		.enabled = 1, .loglevel = LOGL_NOTICE,
+	},
+};
+
+int gtphub_log_filter_fn(const struct log_context *ctx,
+			 struct log_target *tar)
+{
+	return 0;
+}
+
+static const struct log_info gtphub_log_info = {
+	.filter_fn = gtphub_log_filter_fn,
+	.cat = gtphub_categories,
+	.num_cat = ARRAY_SIZE(gtphub_categories),
+};
+
+void log_cfg(struct gtphub_cfg *cfg)
+{
+	struct gtphub_cfg_addr *a;
+	a = &cfg->to_sgsns[GTPH_PORT_CTRL].bind;
+	LOG("to-SGSNs bind, Control: %s port %d\n",
+	    a->addr_str, a->port);
+	a = &cfg->to_sgsns[GTPH_PORT_USER].bind;
+	LOG("to-SGSNs bind, User:    %s port %d\n",
+	    a->addr_str, a->port);
+	a = &cfg->to_ggsns[GTPH_PORT_CTRL].bind;
+	LOG("to-GGSNs bind, Control: %s port %d\n",
+	    a->addr_str, a->port);
+	a = &cfg->to_ggsns[GTPH_PORT_USER].bind;
+	LOG("to-GGSNs bind, User:    %s port %d\n",
+	    a->addr_str, a->port);
+}
+
+static void signal_handler(int signal)
+{
+	fprintf(stdout, "signal %u received\n", signal);
+
+	switch (signal) {
+	case SIGINT:
+		osmo_signal_dispatch(SS_L_GLOBAL, S_L_GLOBAL_SHUTDOWN, NULL);
+		sleep(1);
+		exit(0);
+		break;
+	case SIGABRT:
+		/* in case of abort, we want to obtain a talloc report
+		 * and then return to the caller, who will abort the process */
+	case SIGUSR1:
+	case SIGUSR2:
+		talloc_report_full(osmo_gtphub_ctx, stderr);
+		break;
+	default:
+		break;
+	}
+}
+
+extern int bsc_vty_go_parent(struct vty *vty);
+
+static struct vty_app_info vty_info = {
+	.name 		= "OsmoGTPhub",
+	.version	= PACKAGE_VERSION,
+	.go_parent_cb	= bsc_vty_go_parent,
+	.is_config_node	= bsc_vty_is_config_node,
+};
+
+struct cmdline_cfg {
+	const char *config_file;
+	int daemonize;
+};
+
+static void print_help(struct cmdline_cfg *ccfg)
+{
+	printf("gtphub commandline options\n");
+	printf("  -h --help            This text.\n");
+	printf("  -D --daemonize       Fork the process into a background daemon.\n");
+	printf("  -d,--debug <cat>     Enable Debugging for this category.\n");
+	printf("                       Pass '-d list' to get a category listing.\n");
+	printf("  -s --disable-color");
+	printf("  -c --config-file     The config file to use [%s].\n", ccfg->config_file);
+	printf("  -e,--log-level <nr>  Set a global log level.\n");
+}
+
+static void list_categories(void)
+{
+	printf("Avaliable debug categories:\n");
+	int i;
+	for (i = 0; i < gtphub_log_info.num_cat; ++i) {
+		if (!gtphub_log_info.cat[i].name)
+			continue;
+
+		printf("%s\n", gtphub_log_info.cat[i].name);
+	}
+}
+
+static void handle_options(struct cmdline_cfg *ccfg, int argc, char **argv)
+{
+	while (1) {
+		int option_index = 0, c;
+		static struct option long_options[] = {
+			{"help", 0, 0, 'h'},
+			{"debug", 1, 0, 'd'},
+			{"daemonize", 0, 0, 'D'},
+			{"config-file", 1, 0, 'c'},
+			{"disable-color", 0, 0, 's'},
+			{"timestamp", 0, 0, 'T'},
+			{"log-level", 1, 0, 'e'},
+			{NULL, 0, 0, 0}
+		};
+
+		c = getopt_long(argc, argv, "hd:Dc:sTe:",
+				long_options, &option_index);
+		if (c == -1)
+			break;
+
+		switch (c) {
+		case 'h':
+			//print_usage();
+			print_help(ccfg);
+			exit(0);
+		case 's':
+			log_set_use_color(osmo_stderr_target, 0);
+			break;
+		case 'd':
+			if (strcmp("list", optarg) == 0) {
+				list_categories();
+				exit(0);
+			}
+			else
+				log_parse_category_mask(osmo_stderr_target, optarg);
+			break;
+		case 'D':
+			ccfg->daemonize = 1;
+			break;
+		case 'c':
+			ccfg->config_file = optarg;
+			break;
+		case 'T':
+			log_set_print_timestamp(osmo_stderr_target, 1);
+			break;
+		case 'e':
+			log_set_log_level(osmo_stderr_target, atoi(optarg));
+			break;
+		default:
+			/* ignore */
+			break;
+		}
+	}
+}
+
+int main(int argc, char **argv)
+{
+	int rc;
+
+	osmo_gtphub_ctx = talloc_named_const(NULL, 0, "osmo_gtphub");
+
+	signal(SIGINT, &signal_handler);
+	signal(SIGABRT, &signal_handler);
+	signal(SIGUSR1, &signal_handler);
+	signal(SIGUSR2, &signal_handler);
+	osmo_init_ignore_signals();
+
+	osmo_init_logging(&gtphub_log_info);
+
+	vty_info.copyright = gtphub_copyright;
+	vty_init(&vty_info);
+	logging_vty_add_cmds(&gtphub_log_info);
+        gtphub_vty_init();
+
+	rate_ctr_init(osmo_gtphub_ctx);
+	rc = telnet_init(osmo_gtphub_ctx, 0, OSMO_VTY_PORT_GTPHUB);
+	if (rc < 0)
+		exit(1);
+
+	struct cmdline_cfg _ccfg;
+	struct cmdline_cfg *ccfg = &_ccfg;
+	memset(ccfg, '\0', sizeof(*ccfg));
+	ccfg->config_file = "./gtphub.conf";
+
+	struct gtphub_cfg _cfg;
+	struct gtphub_cfg *cfg = &_cfg;
+	memset(cfg, '\0', sizeof(*cfg));
+
+	struct gtphub _hub;
+	struct gtphub *hub = &_hub;
+
+	handle_options(ccfg, argc, argv);
+
+	rc = gtphub_cfg_read(cfg, ccfg->config_file);
+	if (rc < 0) {
+		LOGP(DGTPHUB, LOGL_FATAL, "Cannot parse config file '%s'\n", ccfg->config_file);
+		exit(2);
+	}
+
+	if (gtphub_start(hub, cfg) != 0)
+		return -1;
+
+	log_cfg(cfg);
+
+	if (ccfg->daemonize) {
+		rc = osmo_daemonize();
+		if (rc < 0) {
+			LOGERR("Error during daemonize");
+			exit(1);
+		}
+	}
+
+	while (1) {
+		rc = osmo_select_main(0);
+		if (rc < 0)
+			exit(3);
+	}
+
+	/* not reached */
+	exit(0);
+}
diff --git a/openbsc/src/gprs/gtphub_sep.c b/openbsc/src/gprs/gtphub_sep.c
new file mode 100644
index 0000000..bb31834
--- /dev/null
+++ b/openbsc/src/gprs/gtphub_sep.c
@@ -0,0 +1,26 @@ 
+/* This file is kept separate so that these functions can be wrapped for
+ * gtphub_test.c. When a function and its callers are in the same compilational
+ * unit, the wrappability may be optimized away. */
+#include <string.h>
+
+#include <openbsc/gtphub.h>
+#include <osmocom/core/utils.h>
+
+#define __llist_first(head) (((head)->next == (head)) ? NULL : (head)->next)
+#define llist_first(head, type, entry) llist_entry(__llist_first(head), type, entry)
+
+int gtphub_resolve_ggsn_addr(struct gtphub *hub,
+			     struct osmo_sockaddr *result,
+			     struct gtp_packet_desc *p)
+{
+	/* TODO  This is just hardcodedly returning the first known address.
+	 * Should resolve from actual subscriber data. */
+	struct gtphub_peer *ggsn = llist_first(&hub->to_ggsns[GTPH_PORT_CTRL].peers,
+					       struct gtphub_peer, entry);
+	if (!ggsn)
+		return -1;
+
+	memcpy(result, &ggsn->addr, sizeof(struct osmo_sockaddr));
+	return 0;
+}
+
diff --git a/openbsc/src/gprs/gtphub_vty.c b/openbsc/src/gprs/gtphub_vty.c
new file mode 100644
index 0000000..2227d48
--- /dev/null
+++ b/openbsc/src/gprs/gtphub_vty.c
@@ -0,0 +1,258 @@ 
+/* (C) 2015 by sysmocom s.f.m.c. GmbH
+ * All Rights Reserved
+ *
+ * Author: Neels Hofmeyr
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <string.h>
+
+#include <osmocom/core/talloc.h>
+#include <osmocom/vty/command.h>
+
+#include <openbsc/vty.h>
+#include <openbsc/gtphub.h>
+
+static struct gtphub_cfg *g_cfg = 0;
+
+static struct cmd_node gtphub_node = {
+	GTPHUB_NODE,
+	"%s(config-gtphub)# ",
+	1,
+};
+
+#define GTPH_DEFAULT_CONTROL_PORT 2123
+#define GTPH_DEFAULT_USER_PORT 2152
+
+static void write_addrs(struct vty *vty, const char *name,
+			struct gtphub_cfg_addr *c, struct gtphub_cfg_addr *u)
+{
+	if ((c->port == GTPH_DEFAULT_CONTROL_PORT)
+	    && (u->port == GTPH_DEFAULT_USER_PORT)
+	    && (strcmp(c->addr_str, u->addr_str) == 0)) {
+		/* Default port numbers and same IP address: write "short"
+		 * variant. */
+		vty_out(vty, " %s %s%s",
+			name,
+			c->addr_str,
+			VTY_NEWLINE);
+		return;
+	}
+
+	vty_out(vty, " %s ctrl %s %d user %s %d%s",
+		name,
+		c->addr_str, (int)c->port,
+		u->addr_str, (int)u->port,
+		VTY_NEWLINE);
+}
+
+static int config_write_gtphub(struct vty *vty)
+{
+	vty_out(vty, "gtphub%s", VTY_NEWLINE);
+
+	write_addrs(vty, "bind-to-sgsns",
+		    &g_cfg->to_sgsns[GTPH_PORT_CTRL].bind,
+		    &g_cfg->to_sgsns[GTPH_PORT_USER].bind);
+
+	write_addrs(vty, "bind-to-ggsns",
+		    &g_cfg->to_ggsns[GTPH_PORT_CTRL].bind,
+		    &g_cfg->to_ggsns[GTPH_PORT_USER].bind);
+
+	if (g_cfg->ggsn_proxy[GTPH_PORT_CTRL].addr_str) {
+		write_addrs(vty, "ggsn-proxy",
+			    &g_cfg->ggsn_proxy[GTPH_PORT_CTRL],
+			    &g_cfg->ggsn_proxy[GTPH_PORT_USER]);
+	}
+
+	return CMD_SUCCESS;
+}
+
+DEFUN(cfg_gtphub, cfg_gtphub_cmd,
+      "gtphub",
+      "Configure the GTP hub")
+{
+	vty->node = GTPHUB_NODE;
+	return CMD_SUCCESS;
+}
+
+#define BIND_ARGS  "ctrl ADDR <0-65535> user ADDR <0-65535>"
+#define BIND_DOCS  \
+	"Set GTP-C bind\n" \
+	"GTP-C local IP address (v4 or v6)\n" \
+	"GTP-C local port\n" \
+	"Set GTP-U bind\n" \
+	"GTP-U local IP address (v4 or v6)\n" \
+	"GTP-U local port\n"
+
+
+DEFUN(cfg_gtphub_bind_to_sgsns_short, cfg_gtphub_bind_to_sgsns_short_cmd,
+	"bind-to-sgsns ADDR",
+	"GTP Hub Parameters\n"
+	"Set the local bind address to listen for SGSNs, for both GTP-C and GTP-U\n"
+	"Local IP address (v4 or v6)\n"
+	)
+{
+	int i;
+	for (i = 0; i < GTPH_PORT_N; i++)
+		g_cfg->to_sgsns[i].bind.addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	g_cfg->to_sgsns[GTPH_PORT_CTRL].bind.port = GTPH_DEFAULT_CONTROL_PORT;
+	g_cfg->to_sgsns[GTPH_PORT_USER].bind.port = GTPH_DEFAULT_USER_PORT;
+	return CMD_SUCCESS;
+}
+
+DEFUN(cfg_gtphub_bind_to_ggsns_short, cfg_gtphub_bind_to_ggsns_short_cmd,
+	"bind-to-ggsns ADDR",
+	"GTP Hub Parameters\n"
+	"Set the local bind address to listen for GGSNs, for both GTP-C and GTP-U\n"
+	"Local IP address (v4 or v6)\n"
+	)
+{
+	int i;
+	for (i = 0; i < GTPH_PORT_N; i++)
+		g_cfg->to_ggsns[i].bind.addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	g_cfg->to_ggsns[GTPH_PORT_CTRL].bind.port = GTPH_DEFAULT_CONTROL_PORT;
+	g_cfg->to_ggsns[GTPH_PORT_USER].bind.port = GTPH_DEFAULT_USER_PORT;
+	return CMD_SUCCESS;
+}
+
+
+static int handle_binds(struct gtphub_cfg_bind *b, const char **argv)
+{
+	b[GTPH_PORT_CTRL].bind.addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	b[GTPH_PORT_CTRL].bind.port = atoi(argv[1]);
+	b[GTPH_PORT_USER].bind.addr_str = talloc_strdup(tall_vty_ctx, argv[2]);
+	b[GTPH_PORT_USER].bind.port = atoi(argv[3]);
+	return CMD_SUCCESS;
+}
+
+DEFUN(cfg_gtphub_bind_to_sgsns, cfg_gtphub_bind_to_sgsns_cmd,
+	"bind-to-sgsns " BIND_ARGS,
+	"GTP Hub Parameters\n"
+	"Set the local bind addresses and ports to listen for SGSNs\n"
+	BIND_DOCS
+	)
+{
+	return handle_binds(g_cfg->to_sgsns, argv);
+}
+
+DEFUN(cfg_gtphub_bind_to_ggsns, cfg_gtphub_bind_to_ggsns_cmd,
+	"bind-to-ggsns " BIND_ARGS,
+	"GTP Hub Parameters\n"
+	"Set the local bind addresses and ports to listen for GGSNs\n"
+	BIND_DOCS
+	)
+{
+	return handle_binds(g_cfg->to_ggsns, argv);
+}
+
+DEFUN(cfg_gtphub_ggsn_proxy_short, cfg_gtphub_ggsn_proxy_short_cmd,
+	"ggsn-proxy ADDR",
+	"GTP Hub Parameters\n"
+	"Redirect all GGSN bound traffic to default ports on this address (another gtphub)\n"
+	"Remote IP address (v4 or v6)\n"
+	)
+{
+	g_cfg->ggsn_proxy[GTPH_PORT_CTRL].addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	g_cfg->ggsn_proxy[GTPH_PORT_CTRL].port = GTPH_DEFAULT_CONTROL_PORT;
+	g_cfg->ggsn_proxy[GTPH_PORT_USER].addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	g_cfg->ggsn_proxy[GTPH_PORT_USER].port = GTPH_DEFAULT_USER_PORT;
+	return CMD_SUCCESS;
+}
+
+DEFUN(cfg_gtphub_ggsn_proxy, cfg_gtphub_ggsn_proxy_cmd,
+	"ggsn-proxy " BIND_ARGS,
+	"GTP Hub Parameters\n"
+	"Redirect all GGSN bound traffic to these addresses and ports (another gtphub)\n"
+	BIND_DOCS
+	)
+{
+	g_cfg->ggsn_proxy[GTPH_PORT_CTRL].addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	g_cfg->ggsn_proxy[GTPH_PORT_CTRL].port = atoi(argv[1]);
+	g_cfg->ggsn_proxy[GTPH_PORT_USER].addr_str = talloc_strdup(tall_vty_ctx, argv[2]);
+	g_cfg->ggsn_proxy[GTPH_PORT_USER].port = atoi(argv[3]);
+	return CMD_SUCCESS;
+}
+
+DEFUN(cfg_gtphub_sgsn_proxy_short, cfg_gtphub_sgsn_proxy_short_cmd,
+	"sgsn-proxy ADDR",
+	"GTP Hub Parameters\n"
+	"Redirect all SGSN bound traffic to default ports on this address (another gtphub)\n"
+	"Remote IP address (v4 or v6)\n"
+	)
+{
+	g_cfg->sgsn_proxy[GTPH_PORT_CTRL].addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	g_cfg->sgsn_proxy[GTPH_PORT_CTRL].port = GTPH_DEFAULT_CONTROL_PORT;
+	g_cfg->sgsn_proxy[GTPH_PORT_USER].addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	g_cfg->sgsn_proxy[GTPH_PORT_USER].port = GTPH_DEFAULT_USER_PORT;
+	return CMD_SUCCESS;
+}
+
+DEFUN(cfg_gtphub_sgsn_proxy, cfg_gtphub_sgsn_proxy_cmd,
+	"sgsn-proxy " BIND_ARGS,
+	"GTP Hub Parameters\n"
+	"Redirect all SGSN bound traffic to these addresses and ports (another gtphub)\n"
+	BIND_DOCS
+	)
+{
+	g_cfg->sgsn_proxy[GTPH_PORT_CTRL].addr_str = talloc_strdup(tall_vty_ctx, argv[0]);
+	g_cfg->sgsn_proxy[GTPH_PORT_CTRL].port = atoi(argv[1]);
+	g_cfg->sgsn_proxy[GTPH_PORT_USER].addr_str = talloc_strdup(tall_vty_ctx, argv[2]);
+	g_cfg->sgsn_proxy[GTPH_PORT_USER].port = atoi(argv[3]);
+	return CMD_SUCCESS;
+}
+
+DEFUN(show_gtphub, show_gtphub_cmd, "show gtphub",
+      SHOW_STR "Display information about the GTP hub")
+{
+	vty_out(vty, "gtphub has nothing to say yet%s", VTY_NEWLINE);
+	return CMD_SUCCESS;
+}
+
+
+int gtphub_vty_init(void)
+{
+	install_element_ve(&show_gtphub_cmd);
+
+	install_element(CONFIG_NODE, &cfg_gtphub_cmd);
+	install_node(&gtphub_node, config_write_gtphub);
+	vty_install_default(GTPHUB_NODE);
+
+	install_element(GTPHUB_NODE, &cfg_gtphub_bind_to_sgsns_short_cmd);
+	install_element(GTPHUB_NODE, &cfg_gtphub_bind_to_sgsns_cmd);
+	install_element(GTPHUB_NODE, &cfg_gtphub_bind_to_ggsns_short_cmd);
+	install_element(GTPHUB_NODE, &cfg_gtphub_bind_to_ggsns_cmd);
+	install_element(GTPHUB_NODE, &cfg_gtphub_ggsn_proxy_short_cmd);
+	install_element(GTPHUB_NODE, &cfg_gtphub_ggsn_proxy_cmd);
+	install_element(GTPHUB_NODE, &cfg_gtphub_sgsn_proxy_short_cmd);
+	install_element(GTPHUB_NODE, &cfg_gtphub_sgsn_proxy_cmd);
+
+	return 0;
+}
+
+int gtphub_cfg_read(struct gtphub_cfg *cfg, const char *config_file)
+{
+	int rc;
+
+	g_cfg = cfg;
+
+	rc = vty_read_config_file(config_file, NULL);
+	if (rc < 0) {
+		fprintf(stderr, "Failed to parse the config file: '%s'\n", config_file);
+		return rc;
+	}
+
+	return 0;
+}
diff --git a/openbsc/tests/Makefile.am b/openbsc/tests/Makefile.am
index 1b557d4..aacfe0b 100644
--- a/openbsc/tests/Makefile.am
+++ b/openbsc/tests/Makefile.am
@@ -1,4 +1,4 @@ 
-SUBDIRS = gsm0408 db channel mgcp gprs abis gbproxy trau subscr oap
+SUBDIRS = gsm0408 db channel mgcp gprs abis gbproxy trau subscr oap gtphub
 
 if BUILD_NAT
 SUBDIRS += bsc-nat bsc-nat-trie
diff --git a/openbsc/tests/gtphub/Makefile.am b/openbsc/tests/gtphub/Makefile.am
new file mode 100644
index 0000000..ecc6d62
--- /dev/null
+++ b/openbsc/tests/gtphub/Makefile.am
@@ -0,0 +1,20 @@ 
+AM_CPPFLAGS = $(all_includes) -I$(top_srcdir)/include
+AM_CFLAGS=-Wall -ggdb3 $(LIBOSMOCORE_CFLAGS)
+
+EXTRA_DIST = \
+	gtphub_test.ok \
+	gtphub_nc_test.sh \
+	gtphub_nc_test.ok \
+	hex2bin.py
+
+noinst_PROGRAMS = gtphub_test
+
+gtphub_test_SOURCES = gtphub_test.c
+gtphub_test_LDFLAGS = \
+	-Wl,--wrap=gtphub_resolve_ggsn_addr
+
+gtphub_test_LDADD = \
+	$(top_builddir)/src/gprs/gtphub.o \
+	$(LIBOSMOCORE_LIBS) \
+	-lgtp -lrt
+
diff --git a/openbsc/tests/gtphub/gtphub_nc_test.gtphub.conf b/openbsc/tests/gtphub/gtphub_nc_test.gtphub.conf
new file mode 100644
index 0000000..17cc756
--- /dev/null
+++ b/openbsc/tests/gtphub/gtphub_nc_test.gtphub.conf
@@ -0,0 +1,5 @@ 
+gtphub
+ bind-to-sgsns ctrl 127.0.0.1 21231 user 127.0.0.1 21521
+ bind-to-ggsns ctrl 127.0.0.1 21232 user 127.0.0.1 21522
+ ggsn-proxy 127.0.0.1
+end
diff --git a/openbsc/tests/gtphub/gtphub_nc_test.ok b/openbsc/tests/gtphub/gtphub_nc_test.ok
new file mode 100644
index 0000000..6a6db53
--- /dev/null
+++ b/openbsc/tests/gtphub/gtphub_nc_test.ok
@@ -0,0 +1,7 @@ 
+--- recv_server:
+32 01 00 04 00 00 00 00 6d 31 00 00
+OK
+--- recv_client:
+32 02 00 06 00 00 00 00 7c 00 00 00 0e 01
+OK
+done
diff --git a/openbsc/tests/gtphub/gtphub_nc_test.sh b/openbsc/tests/gtphub/gtphub_nc_test.sh
new file mode 100755
index 0000000..febb5a7
--- /dev/null
+++ b/openbsc/tests/gtphub/gtphub_nc_test.sh
@@ -0,0 +1,85 @@ 
+#!/usr/bin/env bash
+# gtphub_nc_test.sh
+
+# TODO does this work with all relevant netcat implementations?
+# TODO skip if netcat not found?
+# TODO use only 127.0.0.1 once gtphub is configurable.
+
+dump() {
+  # echo result to strip trailing space
+  echo $(hexdump -v -e '/1 "%02x "' $@)
+}
+
+sendhex() {
+  hex="$1"
+  from_port="$2"
+  to_port="$3"
+  echo "$hex" | ./hex2bin.py | nc --send-only -u -s 127.0.0.1 -p "$from_port" 127.0.0.1 "$to_port"
+}
+
+gtphub_bin="./osmo-gtphub"
+if [ ! -x "$gtphub_bin" ]; then
+	echo "executable not found: $gtphub_bin"
+	exit 1;
+fi
+
+#  client              osmo-gtphub                            gtp server
+#  127.0.0.1:9876 <--> 127.0.0.1:21231 | 127.0.0.1:21232 <--> 127.0.0.1 2123
+#  (netcat)            ($gtphub_bin)                          (netcat)
+
+# start gtphub relay
+"$gtphub_bin" -c gtphub.conf &
+sleep 0.1
+
+# log what reaches client and server
+nc --recv-only -u -l -p 9876 -s 127.0.0.1 > recv_client &
+nc --recv-only -u -l -p 2123 -s 127.0.0.1 > recv_server &
+sleep .1
+
+# send test messages, both ways...
+# When sending the ping, the sequence number gets mapped (7c00 -> 6d31).
+# In the pong, the mapped sequence nr is sent and gets mapped backwards.
+gtp_ping_seq1="32 01 00 04 00 00 00 00 7c 00 00 00"
+gtp_ping_seq2="32 01 00 04 00 00 00 00 6d 31 00 00"
+gtp_pong_seq2="32 02 00 06 00 00 00 00 6d 31 00 00 0e 01"
+gtp_pong_seq1="32 02 00 06 00 00 00 00 7c 00 00 00 0e 01"
+
+sendhex "$gtp_ping_seq1" 9876 21231
+
+# server sends reply with the mapped sequence nr., but wrong sender in terms of
+# the configured GGSN proxy address.
+sendhex "$gtp_pong_seq2" 7777 21232
+
+# server sends reply with the mapped sequence nr., correct sender
+sendhex "$gtp_pong_seq2" 2123 21232
+
+sleep .1
+kill %1 %2 %3
+
+# log what has reached the server and client ends, matched against
+# gtphub_nc_test.ok
+retval=0
+rx_srv="$(dump recv_server)"
+echo "--- recv_server:"
+echo "$rx_srv"
+
+if [ "$rx_srv" = "$gtp_ping_seq2" ]; then
+	echo "OK"
+else
+	echo "*** FAILURE"
+	retval=1
+fi
+
+rx_clt="$(dump recv_client)"
+echo "--- recv_client:"
+echo "$rx_clt"
+
+if [ "$rx_clt" = "$gtp_pong_seq1" ]; then
+	echo "OK"
+else
+	echo "*** FAILURE"
+	retval=2
+fi
+
+echo "done"
+exit "$retval"
diff --git a/openbsc/tests/gtphub/gtphub_test.c b/openbsc/tests/gtphub/gtphub_test.c
new file mode 100644
index 0000000..5a5c4d1
--- /dev/null
+++ b/openbsc/tests/gtphub/gtphub_test.c
@@ -0,0 +1,675 @@ 
+/* Test the GTP hub */
+
+/* (C) 2015 by sysmocom s.f.m.c. GmbH
+ * All Rights Reserved
+ *
+ * Author: Neels Hofmeyr <nhofmeyr@sysmcom.de>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include <stdio.h>
+#include <string.h>
+#include <limits.h>
+#include <unistd.h>
+
+#include <osmocom/core/utils.h>
+#include <osmocom/core/msgb.h>
+#include <osmocom/core/application.h>
+
+#include <openbsc/debug.h>
+
+#include <openbsc/gtphub.h>
+#include <gtp.h>
+#include <gtpie.h>
+
+#define EXPIRE_ALL (gtphub_now() + (60 * GTPH_TEI_MAPPING_EXPIRY_MINUTES) + 1)
+
+/* Make non-public API accessible */
+
+void gtphub_init(struct gtphub *hub);
+
+int gtphub_from_sgsns_handle_buf(struct gtphub *hub,
+				 unsigned int port_idx,
+				 const struct osmo_sockaddr *from_addr,
+				 uint8_t *buf,
+				 size_t received,
+				 struct osmo_fd **to_ofd,
+				 struct osmo_sockaddr **to_addr);
+
+int gtphub_from_ggsns_handle_buf(struct gtphub *hub,
+				 unsigned int port_idx,
+				 const struct osmo_sockaddr *from_addr,
+				 uint8_t *buf,
+				 size_t received,
+				 struct osmo_fd **to_ofd,
+				 struct osmo_sockaddr **to_addr);
+
+void *osmo_gtphub_ctx;
+
+/* TODO copied from libosmo-abis/src/subchan_demux.c, remove dup */
+static int llist_len(struct llist_head *head)
+{
+	struct llist_head *entry;
+	int i = 0;
+
+	llist_for_each(entry, head)
+		i++;
+
+	return i;
+}
+
+static void nr_mapping_free(struct nr_mapping *m)
+{
+	talloc_free(m);
+}
+
+static struct nr_mapping *nr_mapping_alloc(void)
+{
+	struct nr_mapping *m;
+	m = talloc(osmo_gtphub_ctx, struct nr_mapping);
+	nr_mapping_init(m);
+	m->del_cb = nr_mapping_free;
+	return m;
+}
+
+static struct nr_mapping *nr_map_have(struct nr_map *map, void *origin, nr_t orig, time_t now)
+{
+	struct nr_mapping *mapping;
+
+	mapping = nr_map_get(map, origin, orig);
+	if (!mapping) {
+		mapping = nr_mapping_alloc();
+		mapping->origin = origin;
+		mapping->orig = orig;
+		nr_map_add(map, mapping, now);
+	}
+
+	return mapping;
+}
+
+static nr_t nr_map_verify(const struct nr_map *map, void *origin, nr_t orig, nr_t expect_repl)
+{
+	struct nr_mapping *m;
+	m = nr_map_get(map, origin, orig);
+
+	if (!m) {
+		printf("mapping not found for %p %d\n", origin, orig);
+		return 0;
+	}
+
+	if (m->repl != expect_repl) {
+		printf("mapping found, but nr mismatches: expect %d, got %d\n",
+		       (int)expect_repl, (int)m->repl);
+		return 0;
+	}
+
+	return 1;
+}
+
+static int nr_map_verify_inv(const struct nr_map *map, nr_t repl,
+			     void *expect_origin, nr_t expect_orig)
+{
+	struct nr_mapping *m;
+	m = nr_map_get_inv(map, repl);
+	if (!m) {
+		printf("mapping not found for %d\n", (int)repl);
+		return 0;
+	}
+
+	if (m->origin != expect_origin) {
+		printf("mapping found, but origin mismatches: expect %p, got %p\n",
+		       expect_origin, m->origin);
+		return 0;
+	}
+
+	if (m->orig != expect_orig) {
+		printf("mapping found, but nr mismatches: expect %d, got %d\n",
+		       (int)expect_orig, (int)m->orig);
+		return 0;
+	}
+
+	return 1;
+}
+
+
+static void test_nr_map_basic(void)
+{
+	struct nr_pool _pool;
+	struct nr_pool *pool = &_pool;
+	struct nr_map _map;
+	struct nr_map *map = &_map;
+
+	nr_pool_init(pool);
+	nr_map_init(map, pool, NULL);
+
+	OSMO_ASSERT(llist_empty(&map->mappings));
+
+#define TEST_N_HALF 100
+#define TEST_N (2*TEST_N_HALF)
+#define TEST_I 123
+	uint32_t i, check_i;
+	uint32_t m[TEST_N];
+	struct nr_mapping *mapping;
+
+	/* create half of TEST_N mappings from one origin */
+	void *origin1 = (void*)0x1234;
+	for (i = 0; i < TEST_N_HALF; i++) {
+		nr_t orig = TEST_I + i;
+		mapping = nr_map_have(map, origin1, orig, 0);
+		m[i] = mapping->repl;
+		OSMO_ASSERT(m[i] != 0);
+		OSMO_ASSERT(llist_len(&map->mappings) == (i+1));
+		for (check_i = 0; check_i < i; check_i++)
+			OSMO_ASSERT(m[check_i] != m[i]);
+	}
+	OSMO_ASSERT(llist_len(&map->mappings) == TEST_N_HALF);
+
+	/* create another TEST_N mappings with the same original numbers, but
+	 * from a different origin */
+	void *origin2 = (void*)0x5678;
+	for (i = 0; i < TEST_N_HALF; i++) {
+		int i2 = TEST_N_HALF + i;
+		nr_t orig = TEST_I + i;
+		mapping = nr_map_have(map, origin2, orig, 0);
+		m[i2] = mapping->repl;
+		OSMO_ASSERT(m[i2] != 0);
+		OSMO_ASSERT(llist_len(&map->mappings) == (i2+1));
+		for (check_i = 0; check_i < i2; check_i++)
+			OSMO_ASSERT(m[check_i] != m[i2]);
+	}
+	OSMO_ASSERT(llist_len(&map->mappings) == TEST_N);
+
+	/* verify mappings */
+	for (i = 0; i < TEST_N_HALF; i++) {
+		nr_t orig = TEST_I + i;
+		{
+			OSMO_ASSERT(nr_map_verify(map, origin1, orig, m[i]));
+			OSMO_ASSERT(nr_map_verify_inv(map, m[i], origin1, orig));
+		}
+		{
+			int i2 = TEST_N_HALF + i;
+			OSMO_ASSERT(nr_map_verify(map, origin2, orig, m[i2]));
+			OSMO_ASSERT(nr_map_verify_inv(map, m[i2], origin2, orig));
+		}
+	}
+
+	/* remove all mappings */
+	for (i = 0; i < TEST_N_HALF; i++) {
+		OSMO_ASSERT(llist_len(&map->mappings) == (TEST_N - 2*i));
+
+		nr_t orig = TEST_I + i;
+		nr_mapping_del(nr_map_get(map, origin1, orig));
+		nr_mapping_del(nr_map_get(map, origin2, orig));
+	}
+	OSMO_ASSERT(llist_empty(&map->mappings));
+#undef TEST_N
+#undef TEST_I
+}
+
+static int seqmap_is(struct nr_map *map, const char *str)
+{
+	static char buf[4096];
+	char *pos = buf;
+	size_t len = sizeof(buf);
+	struct nr_mapping *m;
+	llist_for_each_entry(m, &map->mappings, entry) {
+		size_t wrote = snprintf(pos, len, "(%d->%d@%d), ",
+					(int)m->orig,
+					(int)m->repl,
+					(int)m->expiry);
+		OSMO_ASSERT(wrote < len);
+		pos += wrote;
+		len -= wrote;
+	}
+	*pos = '\0';
+
+	if (strncmp(buf, str, sizeof(buf)) != 0) {
+		printf("FAILURE: seqmap_is() mismatches expected value:\n"
+		       "expected: %s\n"
+		       "is:       %s\n",
+		       str, buf);
+		return 0;
+	}
+	return 1;
+}
+
+static void test_nr_map_expiry(void)
+{
+	struct nr_map_expiry expiry;
+	struct nr_pool pool;
+	struct nr_map map;
+	int i;
+
+	nr_map_expiry_init(&expiry, 30);
+	nr_pool_init(&pool);
+	nr_map_init(&map, &pool, &expiry);
+	OSMO_ASSERT(seqmap_is(&map, ""));
+
+	/* tick on empty map */
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10000) == 0);
+	OSMO_ASSERT(seqmap_is(&map, ""));
+
+#define MAP1 \
+	"(10->1@10040), " \
+	""
+
+#define MAP2 \
+	"(20->2@10050), " \
+	"(21->3@10051), " \
+	"(22->4@10052), " \
+	"(23->5@10053), " \
+	"(24->6@10054), " \
+	"(25->7@10055), " \
+	"(26->8@10056), " \
+	"(27->9@10057), " \
+	""
+
+#define MAP3 \
+	"(420->10@10072), " \
+	"(421->11@10072), " \
+	"(422->12@10072), " \
+	"(423->13@10072), " \
+	"(424->14@10072), " \
+	"(425->15@10072), " \
+	"(426->16@10072), " \
+	"(427->17@10072), " \
+	""
+
+	/* add mapping at time 10010. */
+	nr_map_have(&map, 0, 10, 10010);
+	OSMO_ASSERT(seqmap_is(&map, MAP1));
+
+	/* tick on unexpired item. */
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10010) == 0);
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10011) == 0);
+	OSMO_ASSERT(seqmap_is(&map, MAP1));
+
+	/* Spread mappings at 10020, 10021, ... 10027. */
+	for (i = 0; i < 8; i++)
+		nr_map_have(&map, 0, 20 + i, 10020 + i);
+	OSMO_ASSERT(seqmap_is(&map, MAP1 MAP2));
+
+	/* tick on unexpired items. */
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10030) == 0);
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10039) == 0);
+	OSMO_ASSERT(seqmap_is(&map, MAP1 MAP2));
+
+	/* expire the first item (from 10010). */
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10010 + 30) == 1);
+	OSMO_ASSERT(seqmap_is(&map, MAP2));
+
+	/* again nothing to expire */
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10041) == 0);
+	OSMO_ASSERT(seqmap_is(&map, MAP2));
+
+	/* Mappings all at the same time. */
+	for (i = 0; i < 8; i++)
+		nr_map_have(&map, 0, 420 + i, 10042);
+	OSMO_ASSERT(seqmap_is(&map, MAP2 MAP3));
+
+	/* Eight to expire, were added further above to be chronologically
+	 * correct, at 10020..10027. */
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10027 + 30) == 8);
+	OSMO_ASSERT(seqmap_is(&map, MAP3));
+
+	/* again nothing to expire */
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10027 + 30) == 0);
+	OSMO_ASSERT(seqmap_is(&map, MAP3));
+
+	/* Eight to expire, from 10042. Now at 10042 + 30: */
+	OSMO_ASSERT(nr_map_expiry_tick(&expiry, 10042 + 30) == 8);
+	OSMO_ASSERT(seqmap_is(&map, ""));
+
+#undef MAP1
+#undef MAP2
+#undef MAP3
+}
+
+
+
+/* override, requires '-Wl,--wrap=gtphub_resolve_ggsn_addr' */
+int __real_gtphub_resolve_ggsn_addr(struct gtphub *hub,
+				    struct osmo_sockaddr *result,
+				    struct gtp_packet_desc *p);
+
+struct osmo_sockaddr resolved_ggsn_addr = {.l = 0};
+int __wrap_gtphub_resolve_ggsn_addr(struct gtphub *hub,
+				    struct osmo_sockaddr *result,
+				    struct gtp_packet_desc *p)
+{
+	osmo_sockaddr_copy(result, &resolved_ggsn_addr);
+	printf("Wrap: returning GGSN addr: %s\n",
+	       osmo_sockaddr_to_str(result));
+	return (resolved_ggsn_addr.l != 0)? 0 : -1;
+}
+
+#define buf_len 1024
+static uint8_t buf[buf_len];
+
+static unsigned int msg(const char *hex)
+{
+	unsigned int l = osmo_hexparse(hex, buf, buf_len);
+	OSMO_ASSERT(l > 0);
+	return l;
+}
+
+/* Compare static buf to given string constant. The amount of bytes is obtained
+ * from parsing the GTP header in buf.  hex must match an osmo_hexdump() of the
+ * desired message. Return 1 if size and content match. */
+#define msg_is(MSG) _msg_is(MSG, __FILE__, __LINE__)
+static int _msg_is(const char *hex, const char *file, int line)
+{
+	struct gtp1_header_long *h = (void*)buf;
+	int len = ntoh16(h->length) + 8;
+	const char *dump = osmo_hexdump_nospc(buf, len);
+	int cmp = strcmp(dump, hex);
+
+	if (cmp != 0) {
+		printf("\n%s:%d: msg_is(): MISMATCH\n"
+		       "  expecting:\n'%s'\n"
+		       "        got:\n'%s'\n",
+		       file,
+		       line,
+		       hex, dump);
+		int i;
+		int l = strlen(hex);
+		int m = strlen(dump);
+		if (m < l)
+			l = m;
+		for (i = 0; i < l; i++) {
+			if (hex[i] != dump[i]) {
+				printf("First mismatch at position %d:\n"
+				       "  %s\n  %s\n", i, hex + i, dump + i);
+				break;
+			}
+		}
+	}
+	return cmp == 0;
+}
+
+#define same_addr(GOT, EXPECTED) _same_addr((GOT),(EXPECTED), __FILE__, __LINE__)
+static int _same_addr(const struct osmo_sockaddr *got,
+		      const struct osmo_sockaddr *expected,
+		      const char *file, int line)
+{
+	int cmp = osmo_sockaddr_cmp(got, expected);
+	if (!cmp)
+		return 1;
+	char buf[256];
+	printf("\n%s:%d: addr_is(): MISMATCH\n"
+	       "  expecting: '%s'\n"
+	       "        got: '%s'\n",
+	       file, line,
+	       osmo_sockaddr_to_str(expected),
+	       osmo_sockaddr_to_strb(got, buf, sizeof(buf)));
+	return 0;
+}
+
+static void test_echo(void)
+{
+	struct gtphub _hub;
+	struct gtphub *hub = &_hub;
+
+	gtphub_init(hub);
+
+	const char *gtp_ping_from_sgsn =
+		"32"	/* 0b001'1 0010: version 1, protocol GTP, with seq nr. */
+		"01"	/* type 01: Echo request */
+		"0004"	/* length of 4 after header TEI */
+		"00000000" /* header TEI == 0 in Echo */
+		"abcd"	/* some 16 octet sequence nr */
+		"0000"	/* N-PDU 0, no extension header (why is this here?) */
+		;
+
+	/* Same with mapped sequence number */
+	const char *gtp_ping_to_ggsn =
+		"32" "01" "0004" "00000000"
+		"6d31"	/* mapped seq */
+		"00" "00";
+
+	const char *gtp_pong_from_ggsn =
+		"32"
+		"02"	/* type 02: Echo response */
+		"0006"	/* len */
+		"00000000" /* tei */
+		"6d31"	/* mapped seq */
+		"0000"	/* ext */
+		"0e01"	/* 0e: Recovery, val == 1 */
+		;
+	/* Same with unmapped sequence number */
+	const char *gtp_pong_to_sgsn =
+		"32" "02" "0006" "00000000"
+		"abcd"	/* unmapped seq */
+		"00" "00" "0e01";
+
+	/* Set the GGSN address that gtphub is forced to resolve to. */
+	OSMO_ASSERT(osmo_sockaddr_init_udp(&resolved_ggsn_addr,
+					   "192.168.43.34", 434)
+		    == 0);
+
+	/* according to spec, we'd always send to port 2123 instead...
+	struct osmo_sockaddr ggsn_standard_port;
+	OSMO_ASSERT(osmo_sockaddr_init_udp(&ggsn_standard_port,
+					   "192.168.43.34", 2123)
+		    == 0);
+	 */
+
+	struct osmo_sockaddr orig_sgsn_addr;
+	OSMO_ASSERT(osmo_sockaddr_init(&orig_sgsn_addr,
+				       AF_UNSPEC, SOCK_DGRAM, IPPROTO_UDP,
+				       "192.168.42.23", 423) == 0);
+	struct osmo_fd *ggsn_ofd = NULL;
+	struct osmo_sockaddr *ggsn_addr = NULL;
+	int send;
+	send = gtphub_from_sgsns_handle_buf(hub, GTPH_PORT_CTRL, &orig_sgsn_addr,
+					    buf, msg(gtp_ping_from_sgsn),
+					    &ggsn_ofd, &ggsn_addr);
+	OSMO_ASSERT(send > 0);
+	OSMO_ASSERT(ggsn_addr);
+	OSMO_ASSERT(same_addr(ggsn_addr, &resolved_ggsn_addr));
+	OSMO_ASSERT(msg_is(gtp_ping_to_ggsn));
+
+	struct osmo_fd *sgsn_ofd;
+	struct osmo_sockaddr *sgsn_addr;
+	send = gtphub_from_ggsns_handle_buf(hub, GTPH_PORT_CTRL, ggsn_addr,
+					    buf, msg(gtp_pong_from_ggsn),
+					    &sgsn_ofd, &sgsn_addr);
+	OSMO_ASSERT(send > 0);
+	OSMO_ASSERT(sgsn_addr);
+	OSMO_ASSERT(same_addr(sgsn_addr, &orig_sgsn_addr));
+	OSMO_ASSERT(msg_is(gtp_pong_to_sgsn));
+
+	gtphub_gc(hub, EXPIRE_ALL);
+}
+
+static void test_create_pdp_ctx(void)
+{
+	struct gtphub _hub;
+	struct gtphub *hub = &_hub;
+
+	gtphub_init(hub);
+
+	/* This is copied from a packet that sgsnemu sends. */
+	const char *gtp_req_from_sgsn =
+		"32" 	/* 0b001'1 0010: version 1, protocol GTP, with seq nr. */
+		"10" 	/* type 16: Create PDP Context Request */
+		"0067"	/* length = 8 + 103 */
+		"00000000" /* No TEI yet */
+		"abcd"	/* Sequence nr */
+		"00"	/* N-PDU 0 */
+		"00"	/* No extensions */
+		/* IEs */
+		"02"	/* 2 = IMSI */
+		  "42000121436587f9"
+		"0e" "60" /* 14: Recovery = 96 */
+		"0f01"	/* 15: Selection mode = MS provided APN, subscription not verified*/
+		"10"	/* 16: TEI Data I */
+		  "00000123"
+		"11"	/* 17: TEI Control Plane */
+		  "00000321"
+		"1400"	/* 20: NSAPI = 0*/
+		"1a"	/* 26: Charging Characteristics */
+		  "0800"
+		"80"	/* 128: End User Address */
+		  "0002" /* length = 2: empty PDP Address */
+		  "f121" /* spare 0xf0, PDP organization 1, PDP type number 0x21 = 33 */
+		"83"	/* 131: Access Point Name */
+		  "0008" /* length = 8 */
+		  "696e7465726e6574" /* "internet" */
+		"84"	/* 132: Protocol Configuration Options */
+		  "0015" /* length = 21 */
+		  "80c0231101010011036d69670868656d6d656c6967"
+		"85"	/* 133: GSN Address */
+		  "0004" /* length */
+		  "7f000001"
+		"85"	/* 133: GSN Address (second entry) */
+		  "0004" /* length */
+		  "7f000001"
+		"86"	/* 134: MS International PSTN/ISDN Number (MSISDN) */
+		  "0007" /* length */
+		  "916407123254f6" /* 1946702123456(f) */
+		"87"	/* 135: Quality of Service (QoS) Profile */
+		  "0004" /* length */
+		  "00"	/* priority */
+		  "0b921f" /* QoS profile data */
+		;
+
+	const char *gtp_req_to_ggsn =
+		"32" "10" "0067" "00000000"
+		"6d31"	/* mapped seq ("abcd") */
+		"00" "00" "02" "42000121436587f9" "0e60" "0f01"
+		"10" "00000001" /* mapped TEI Data I ("123") */
+		"11" "00000001" /* mapped TEI Control ("321") */
+		"1400" "1a" "0800" "80" "0002" "f121" "83"
+		"0008" "696e7465726e6574" "84" "0015"
+		"80c0231101010011036d69670868656d6d656c6967" "85" "0004"
+		"7f000001" "85" "0004" "7f000001" "86" "0007" "916407123254f6"
+		"87" "0004" "00" "0b921f"
+		;
+
+	const char *gtp_resp_from_ggsn =
+		"32"
+		"11"	/* Create PDP Context Response */
+		"004e"	/* length = 78 + 8 */
+		"00000001" /* destination TEI (sent in req above) */
+		"6d31"	/* mapped seq */
+		"00" "00"
+		/* IEs */
+		"01"	/* 1: Cause */
+		  "80"	/* value = 0b10000000 = response, no rejection. */
+		"08"	/* 8: Reordering Required */
+		  "00"	/* not required. */
+		"0e" "01" /* 14: Recovery = 1 */
+		"10"	/* 16: TEI Data I */
+		  "00000567"
+		"11"	/* 17: TEI Control */
+		  "00000765"
+		"7f"	/* 127: Charging ID */
+		  "00000001"
+		"80"	/* 128: End User Address */
+		  "0006" /* length = 6 */
+		  "f121" /* spare 0xf0, PDP organization 1, PDP type number 0x21 = 33 */
+		  "7f000002"
+		"84"	/* 132: Protocol Configuration Options */
+		  "0014" /* len = 20 */
+		  "8080211002000010810608080808830600000000"
+		"85"	/* 133: GSN Address */
+		  "0004" /* length */
+		  "7f000002"
+		"85"	/* 133: GSN Address (again) */
+		  "0004" /* length */
+		  "7f000002"
+		"87"	/* 135: Quality of Service (QoS) Profile */
+		  "0004" /* length */
+		  "00"	/* priority */
+		  "0b921f" /* QoS profile data */
+		;
+
+	const char *gtp_resp_to_sgsn =
+		"32" "11" "004e"
+		"00000321" /* unmapped TEI ("001") */
+		"abcd" /* unmapped seq ("6d31") */
+		"00" "00" "01" "80" "08" "00" "0e" "01"
+		"10" "00000001" /* mapped TEI from GGSN ("567") */
+		"11" "00000001" /* mapped TEI from GGSN ("765") */
+		"7f" "00000001" "80" "0006" "f121" "7f000002" "84" "0014"
+		"8080211002000010810608080808830600000000" "85" "0004"
+		"7f000002" "85" "0004" "7f000002" "87" "0004" "00" "0b921f"
+		;
+
+	/* Set the GGSN address that gtphub is forced to resolve to. */
+	OSMO_ASSERT(osmo_sockaddr_init_udp(&resolved_ggsn_addr,
+					   "192.168.43.34", 434)
+		    == 0);
+
+	struct osmo_sockaddr orig_sgsn_addr;
+	OSMO_ASSERT(osmo_sockaddr_init(&orig_sgsn_addr,
+				       AF_UNSPEC, SOCK_DGRAM, IPPROTO_UDP,
+				       "192.168.42.23", 423) == 0);
+	struct osmo_fd *ggsn_ofd = NULL;
+	struct osmo_sockaddr *ggsn_addr = NULL;
+	int send;
+	send = gtphub_from_sgsns_handle_buf(hub, GTPH_PORT_CTRL, &orig_sgsn_addr,
+					    buf, msg(gtp_req_from_sgsn),
+					    &ggsn_ofd, &ggsn_addr);
+	OSMO_ASSERT(send > 0);
+	OSMO_ASSERT(ggsn_addr);
+	OSMO_ASSERT(same_addr(ggsn_addr, &resolved_ggsn_addr));
+	OSMO_ASSERT(msg_is(gtp_req_to_ggsn));
+
+	struct osmo_fd *sgsn_ofd;
+	struct osmo_sockaddr *sgsn_addr;
+	send = gtphub_from_ggsns_handle_buf(hub, GTPH_PORT_CTRL, ggsn_addr,
+					    buf, msg(gtp_resp_from_ggsn),
+					    &sgsn_ofd, &sgsn_addr);
+	OSMO_ASSERT(send > 0);
+	OSMO_ASSERT(sgsn_addr);
+	OSMO_ASSERT(same_addr(sgsn_addr, &orig_sgsn_addr));
+	OSMO_ASSERT(msg_is(gtp_resp_to_sgsn));
+
+	gtphub_gc(hub, EXPIRE_ALL);
+}
+
+static struct log_info_cat gtphub_categories[] = {
+	[DGTPHUB] = {
+		.name = "DGTPHUB",
+		.description = "GTP Hub",
+		.color = "\033[1;33m",
+		.enabled = 1, .loglevel = LOGL_NOTICE,
+	},
+};
+
+static struct log_info info = {
+	.cat = gtphub_categories,
+	.num_cat = ARRAY_SIZE(gtphub_categories),
+};
+
+int main(int argc, char **argv)
+{
+	osmo_init_logging(&info);
+	osmo_gtphub_ctx = talloc_named_const(NULL, 0, "osmo_gtphub");
+
+	test_nr_map_basic();
+	test_nr_map_expiry();
+	test_echo();
+	test_create_pdp_ctx();
+	printf("Done\n");
+
+	talloc_report_full(osmo_gtphub_ctx, stderr);
+	OSMO_ASSERT(talloc_total_blocks(osmo_gtphub_ctx) == 1);
+	return 0;
+}
+
diff --git a/openbsc/tests/gtphub/gtphub_test.ok b/openbsc/tests/gtphub/gtphub_test.ok
new file mode 100644
index 0000000..7be13fe
--- /dev/null
+++ b/openbsc/tests/gtphub/gtphub_test.ok
@@ -0,0 +1,3 @@ 
+Wrap: returning GGSN addr: 192.168.43.34 port 434
+Wrap: returning GGSN addr: 192.168.43.34 port 434
+Done
diff --git a/openbsc/tests/gtphub/hex2bin.py b/openbsc/tests/gtphub/hex2bin.py
new file mode 100755
index 0000000..6a906b1
--- /dev/null
+++ b/openbsc/tests/gtphub/hex2bin.py
@@ -0,0 +1,13 @@ 
+#!/usr/bin/env python
+
+import sys
+
+blob = []
+
+for l in sys.stdin.readlines():
+  l = ''.join(c for c in l if not c.isspace())
+
+  for i in range(0, len(l), 2):
+    h = l[i:i+2]
+    sys.stdout.write(chr(int(h, 16)))
+
diff --git a/openbsc/tests/testsuite.at b/openbsc/tests/testsuite.at
index 78aa47e..3a5883e 100644
--- a/openbsc/tests/testsuite.at
+++ b/openbsc/tests/testsuite.at
@@ -110,3 +110,15 @@  AT_CHECK([test "$enable_oap_test" != no || exit 77])
 cat $abs_srcdir/oap/oap_test.ok > expout
 AT_CHECK([$abs_top_builddir/tests/oap/oap_test], [], [expout], [ignore])
 AT_CLEANUP
+
+AT_SETUP([gtphub])
+AT_KEYWORDS([gtphub])
+AT_CHECK([test "$enable_gtphub_test" != no || exit 77])
+cat $abs_srcdir/gtphub/gtphub_test.ok > expout
+AT_CHECK([$abs_top_builddir/tests/gtphub/gtphub_test], [], [expout], [ignore])
+cat $abs_srcdir/gtphub/gtphub_nc_test.ok > expout
+cat $abs_srcdir/gtphub/gtphub_nc_test.gtphub.conf > gtphub.conf
+ln -s $abs_srcdir/gtphub/hex2bin.py .
+ln -s $abs_top_builddir/src/gprs/osmo-gtphub .
+AT_CHECK([$abs_top_builddir/tests/gtphub/gtphub_nc_test.sh], [], [expout], [ignore])
+AT_CLEANUP