diff --git a/Documentation/netlink/specs/ultraeth.yaml b/Documentation/netlink/specs/ultraeth.yaml
index 847f748efa52..3dc10e52131e 100644
--- a/Documentation/netlink/specs/ultraeth.yaml
+++ b/Documentation/netlink/specs/ultraeth.yaml
@@ -22,6 +22,15 @@ attribute-sets:
       -
         name: netdev-name
         type: string
+      -
+        name: chardev-name
+        type: string
+      -
+        name: chardev-major
+        type: s32
+      -
+        name: chardev-minor
+        type: s32
   -
     name: contexts
     attributes:
diff --git a/drivers/ultraeth/Makefile b/drivers/ultraeth/Makefile
index f2d6a8569dbf..bee8e7aa00bb 100644
--- a/drivers/ultraeth/Makefile
+++ b/drivers/ultraeth/Makefile
@@ -1,4 +1,4 @@
 obj-$(CONFIG_ULTRAETH) += ultraeth.o
 
 ultraeth-objs := uet_main.o uet_context.o uet_netlink.o uet_job.o \
-			uecon.o uet_pdc.o uet_pds.o
+			uecon.o uet_pdc.o uet_pds.o uet_chardev.o
diff --git a/drivers/ultraeth/uet_chardev.c b/drivers/ultraeth/uet_chardev.c
new file mode 100644
index 000000000000..f02f2c1e1afd
--- /dev/null
+++ b/drivers/ultraeth/uet_chardev.c
@@ -0,0 +1,264 @@
+// SPDX-License-Identifier: ((GPL-2.0 WITH Linux-syscall-note) OR BSD-3-Clause)
+
+#include <linux/kernel.h>
+#include <linux/module.h>
+#include <linux/miscdevice.h>
+#include <linux/netdevice.h>
+
+#include <rdma/ib_umem.h>
+
+#include <uapi/linux/ultraeth.h>
+#include <net/ultraeth/uet_context.h>
+#include <net/ultraeth/uet_chardev.h>
+
+#define MAX_PDS_HDRLEN	64	/* -ish? */
+
+static int uet_char_open(struct inode *inode, struct file *file)
+{
+	struct uet_context *ctx;
+	struct uet_fep *fep;
+	int rv;
+
+	ctx = uet_context_get_by_minor(iminor(inode));
+	if (!ctx)
+		return -ENOENT;
+
+	fep = kzalloc(sizeof(*fep), GFP_KERNEL);
+	if (!fep) {
+		rv = -ENOMEM;
+		goto err_alloc;
+	}
+
+	fep->context = ctx;
+	fep->ack_gen_min_pkt_add = UET_DEFAULT_ACK_GEN_MIN_PKT_ADD;
+	fep->ack_gen_trigger = UET_DEFAULT_ACK_GEN_TRIGGER;
+	skb_queue_head_init(&fep->rxq);
+	file->private_data = fep;
+	rv = nonseekable_open(inode, file);
+	if (rv < 0)
+		goto err_open;
+
+	return rv;
+
+err_open:
+	kfree(fep);
+err_alloc:
+	uet_context_put(ctx);
+
+	return rv;
+}
+
+static int uet_char_release(struct inode *inode, struct file *file)
+{
+	struct uet_fep *fep = file->private_data;
+
+	uet_job_reg_disassociate(&fep->context->job_reg, fep->job_id);
+	skb_queue_purge(&fep->rxq);
+	uet_context_put(fep->context);
+	kfree(fep);
+
+	return 0;
+}
+
+static long uet_char_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
+{
+	struct uet_fep *fep = file->private_data;
+	void __user *p = (void __user *)arg;
+	int ret = 0;
+
+	switch (cmd) {
+	case UET_ADDR_REQ: {
+		struct uet_job_addr_req areq;
+
+		if (copy_from_user(&areq, p, sizeof(areq)))
+			return -EFAULT;
+		// XXX: validate address
+
+		areq.service_name[UET_SVC_MAX_LEN - 1] = '\0';
+		memcpy(&fep->addr.in_address, &areq.address,
+		       sizeof(fep->addr.in_address));
+
+		ret = uet_job_reg_associate(&fep->context->job_reg, fep,
+					    areq.service_name);
+		if (!ret) {
+			if (areq.ack_gen_trigger > 0)
+				fep->ack_gen_trigger = areq.ack_gen_trigger;
+			if (areq.ack_gen_min_pkt_add > 0)
+				fep->ack_gen_min_pkt_add = areq.ack_gen_min_pkt_add;
+		}
+		break;
+	}
+	default:
+		return -EOPNOTSUPP;
+	}
+
+	return ret;
+}
+
+static ssize_t uet_char_read(struct file *file, char __user *ubuf,
+			       size_t usize, loff_t *off)
+{
+	struct uet_fep *fep = file->private_data;
+	struct uet_prologue_hdr *prologue;
+	struct uet_pds_meta meta = {};
+	struct sk_buff *skb = NULL;
+	int ret = -ENOTCONN;
+	int hdrlen = 0;
+	size_t userlen;
+
+	pr_debug("%s file=%p fep=%p size=%zu\n", __func__, file, fep, usize);
+
+	ret = -EAGAIN;
+	skb = skb_dequeue(&fep->rxq);
+	if (!skb)
+		goto out_err;
+
+	ret = skb_linearize(skb);
+	if (ret)
+		goto out_err;
+
+	prologue = pds_prologue_hdr(skb);
+	meta.next_hdr = uet_prologue_next_hdr(prologue);
+	meta.addr = UET_SKB_CB(skb)->remote_fep_addr;
+	switch (meta.next_hdr) {
+	case UET_PDS_NEXT_HDR_RSP_DATA:
+	case UET_PDS_NEXT_HDR_RSP_DATA_SMALL:
+		/* TODO */
+		ret = -EOPNOTSUPP;
+		goto out_err;
+	case UET_PDS_NEXT_HDR_RSP:
+		hdrlen = sizeof(struct uet_pds_ack_hdr);
+		break;
+	default:
+		hdrlen = sizeof(struct uet_pds_req_hdr);
+		break;
+	}
+	userlen = sizeof(meta) + skb->len - hdrlen;
+	if (userlen > usize) {
+		ret = -EMSGSIZE;
+		goto out_err;
+	}
+
+	if (copy_to_user(ubuf, &meta, sizeof(meta))) {
+		ret = -EFAULT;
+		goto out_err;
+	}
+	if (copy_to_user(ubuf + sizeof(meta), skb->data + hdrlen, skb->len - hdrlen)) {
+		ret = -EFAULT;
+		goto out_err;
+	}
+
+	consume_skb(skb);
+	ret = userlen;
+
+	return ret;
+
+out_err:
+	kfree_skb(skb);
+
+	return ret;
+}
+
+static ssize_t uet_char_write(struct file *file, const char __user *ubuf,
+			      size_t usize, loff_t *off)
+{
+	struct uet_fep *fep = file->private_data;
+	struct sk_buff *skb = NULL;
+	struct uet_pds_meta *meta;
+	struct uet_job *job;
+	__be32 daddr, saddr;
+	int ret = -ENODEV;
+	__be16 dport;
+	void *buf;
+
+	pr_debug("%s file=%p fep=%p size=%zu\n", __func__, file, fep, usize);
+
+	rcu_read_lock();
+	job = uet_job_find(&fep->context->job_reg, fep->job_id);
+	if (!job)
+		goto out_err;
+
+	ret = -ENOMEM;
+	skb = alloc_skb(MAX_HEADER + MAX_PDS_HDRLEN + usize, GFP_ATOMIC);
+	if (!skb)
+		goto out_err;
+	skb_reserve(skb, MAX_HEADER + MAX_PDS_HDRLEN);
+	buf = skb_put(skb, usize);
+	ret = -EFAULT;
+	if (copy_from_user(buf, ubuf, usize))
+		goto out_err;
+
+	print_hex_dump_bytes("pds tx ", DUMP_PREFIX_OFFSET, skb->data, skb->len);
+
+	meta = skb_pull_data(skb, sizeof(*meta));
+	if (!meta) {
+		ret = -EINVAL;
+		goto out_err;
+	}
+	/* TODO: IPv6 */
+	/* TODO: per-packet daddr */
+	saddr = fep->addr.in_address.ip;
+	daddr = meta->addr;
+	dport = meta->port;
+
+	switch (meta->next_hdr) {
+	case UET_PDS_NEXT_HDR_RSP_DATA:
+	case UET_PDS_NEXT_HDR_RSP_DATA_SMALL:
+		ret = -EOPNOTSUPP; /* TODO */
+		goto out_err;
+	case UET_PDS_NEXT_HDR_RSP:
+		ret = 0; /* FIXME: ACK PSN would be wrong */
+		break;
+	default:
+		ret = uet_pds_tx(&fep->context->pds, skb, saddr, daddr, dport,
+				 job->id);
+		break;
+	}
+
+	if (ret < 0)
+		goto out_err;
+	rcu_read_unlock();
+
+	return usize;
+
+out_err:
+	rcu_read_unlock();
+	kfree_skb(skb);
+
+	return ret;
+}
+
+static const struct file_operations uet_char_ops = {
+	.owner		= THIS_MODULE,
+	.open		= uet_char_open,
+	.release	= uet_char_release,
+	.read		= uet_char_read,
+	.write		= uet_char_write,
+	.unlocked_ioctl	= uet_char_ioctl,
+};
+
+#define UET_CHAR_MAX_NAME 20
+
+int uet_char_init(struct miscdevice *cdev, int id)
+{
+	int ret = -ENOMEM;
+
+	cdev->minor = MISC_DYNAMIC_MINOR;
+	cdev->name = kzalloc(UET_CHAR_MAX_NAME, GFP_KERNEL);
+	if (!cdev->name)
+		return ret;
+	snprintf((char *)cdev->name, UET_CHAR_MAX_NAME, "ultraeth%d", id);
+	cdev->fops = &uet_char_ops;
+
+	ret = misc_register(cdev);
+	if (ret)
+		kfree(cdev->name);
+
+	return ret;
+}
+
+void uet_char_uninit(struct miscdevice *cdev)
+{
+	kfree(cdev->name);
+	misc_deregister(cdev);
+}
diff --git a/drivers/ultraeth/uet_context.c b/drivers/ultraeth/uet_context.c
index 6bdd72344e01..7bddc810503b 100644
--- a/drivers/ultraeth/uet_context.c
+++ b/drivers/ultraeth/uet_context.c
@@ -2,6 +2,7 @@
 
 #include <net/ultraeth/uet_context.h>
 #include <net/ultraeth/uecon.h>
+#include <net/ultraeth/uet_chardev.h>
 #include "uet_netlink.h"
 
 #define MAX_CONTEXT_ID 256
@@ -78,6 +79,24 @@ struct uet_context *uet_context_get_by_id(int id)
 	return ctx;
 }
 
+struct uet_context *uet_context_get_by_minor(int minor)
+{
+	struct uet_context *ctx;
+
+	mutex_lock(&uet_context_lock);
+	list_for_each_entry(ctx, &uet_context_list, list) {
+		if (ctx->cdev.minor == minor) {
+			refcount_inc(&ctx->refcnt);
+			goto out;
+		}
+	}
+	ctx = NULL;
+out:
+	mutex_unlock(&uet_context_lock);
+
+	return ctx;
+}
+
 void uet_context_put(struct uet_context *ctx)
 {
 	if (refcount_dec_and_test(&ctx->refcnt))
@@ -111,6 +130,10 @@ int uet_context_create(int id)
 	if (err)
 		goto ctx_pds_err;
 
+	err = uet_char_init(&ctx->cdev, ctx->id);
+	if (err)
+		goto ctx_char_err;
+
 	err = uecon_netdev_init(ctx);
 	if (err)
 		goto ctx_netdev_err;
@@ -120,6 +143,8 @@ int uet_context_create(int id)
 	return 0;
 
 ctx_netdev_err:
+	uet_char_uninit(&ctx->cdev);
+ctx_char_err:
 	uet_pds_uninit(&ctx->pds);
 ctx_pds_err:
 	uet_jobs_uninit(&ctx->job_reg);
@@ -135,6 +160,7 @@ static void __uet_context_destroy(struct uet_context *ctx)
 {
 	uet_context_unlink(ctx);
 	uecon_netdev_uninit(ctx);
+	uet_char_uninit(&ctx->cdev);
 	uet_pds_uninit(&ctx->pds);
 	uet_jobs_uninit(&ctx->job_reg);
 	uet_context_put_id(ctx);
@@ -183,7 +209,10 @@ static int __nl_ctx_fill_one(struct sk_buff *skb,
 
 	if (nla_put_s32(skb, ULTRAETH_A_CONTEXT_ID, ctx->id) ||
 	    nla_put_s32(skb, ULTRAETH_A_CONTEXT_NETDEV_IFINDEX, ctx->netdev->ifindex) ||
-	    nla_put_string(skb, ULTRAETH_A_CONTEXT_NETDEV_NAME, ctx->netdev->name))
+	    nla_put_string(skb, ULTRAETH_A_CONTEXT_NETDEV_NAME, ctx->netdev->name) ||
+	    nla_put_string(skb, ULTRAETH_A_CONTEXT_CHARDEV_NAME, ctx->cdev.name) ||
+	    nla_put_s32(skb, ULTRAETH_A_CONTEXT_CHARDEV_MAJOR, MISC_MAJOR) ||
+	    nla_put_s32(skb, ULTRAETH_A_CONTEXT_CHARDEV_MINOR, ctx->cdev.minor))
 		goto out_err;
 
 	genlmsg_end(skb, hdr);
diff --git a/include/net/ultraeth/uet_chardev.h b/include/net/ultraeth/uet_chardev.h
new file mode 100644
index 000000000000..963b3e247630
--- /dev/null
+++ b/include/net/ultraeth/uet_chardev.h
@@ -0,0 +1,11 @@
+/* SPDX-License-Identifier: ((GPL-2.0 WITH Linux-syscall-note) OR BSD-3-Clause) */
+
+#ifndef _UECON_CHARDEV_H
+#define _UECON_CHAR_H
+
+#include <linux/miscdevice.h>
+
+int uet_char_init(struct miscdevice *cdev, int id);
+void uet_char_uninit(struct miscdevice *cdev);
+
+#endif /* _UECON_CHARDEV_H */
diff --git a/include/net/ultraeth/uet_context.h b/include/net/ultraeth/uet_context.h
index 76077df3bce6..06a5c7f252ac 100644
--- a/include/net/ultraeth/uet_context.h
+++ b/include/net/ultraeth/uet_context.h
@@ -11,6 +11,7 @@
 #include <linux/wait.h>
 #include <net/ultraeth/uet_job.h>
 #include <net/ultraeth/uet_pds.h>
+#include <linux/miscdevice.h>
 
 struct uet_context {
 	int id;
@@ -21,9 +22,11 @@ struct uet_context {
 	struct net_device *netdev;
 	struct uet_job_registry job_reg;
 	struct uet_pds pds;
+	struct miscdevice cdev;
 };
 
 struct uet_context *uet_context_get_by_id(int id);
+struct uet_context *uet_context_get_by_minor(int minor);
 void uet_context_put(struct uet_context *ses_pl);
 
 int uet_context_create(int id);
diff --git a/include/uapi/linux/ultraeth.h b/include/uapi/linux/ultraeth.h
index c1d5457073e1..2843bb710f1e 100644
--- a/include/uapi/linux/ultraeth.h
+++ b/include/uapi/linux/ultraeth.h
@@ -512,4 +512,25 @@ struct fep_address {
 	__u16 padding;
 	__u8 version;
 };
+
+/* char device hacks */
+#define UET_IOCTL_MAGIC	'u'
+#define UET_ADDR_REQ		_IO(UET_IOCTL_MAGIC, 1)
+
+struct uet_job_addr_req {
+	struct fep_in_address address;
+	char service_name[UET_SVC_MAX_LEN];
+	__u32 ack_gen_trigger;
+	__u32 ack_gen_min_pkt_add;
+	__u8 flags;
+};
+
+struct uet_pds_meta {
+	__u8 next_hdr:4;
+	__u8 reserved1:4;
+	__u8 reserved2;
+	__be16 port;
+	/* XXX: fep_address */
+	__be32 addr;
+} __attribute__((packed));
 #endif /* _UAPI_LINUX_ULTRAETH_H */
diff --git a/include/uapi/linux/ultraeth_nl.h b/include/uapi/linux/ultraeth_nl.h
index 515044022906..884fa165adb6 100644
--- a/include/uapi/linux/ultraeth_nl.h
+++ b/include/uapi/linux/ultraeth_nl.h
@@ -13,6 +13,9 @@ enum {
 	ULTRAETH_A_CONTEXT_ID = 1,
 	ULTRAETH_A_CONTEXT_NETDEV_IFINDEX,
 	ULTRAETH_A_CONTEXT_NETDEV_NAME,
+	ULTRAETH_A_CONTEXT_CHARDEV_NAME,
+	ULTRAETH_A_CONTEXT_CHARDEV_MAJOR,
+	ULTRAETH_A_CONTEXT_CHARDEV_MINOR,
 
 	__ULTRAETH_A_CONTEXT_MAX,
 	ULTRAETH_A_CONTEXT_MAX = (__ULTRAETH_A_CONTEXT_MAX - 1)
