Repository: pion/turn
Pull request #311 (2023-03-14 02:01:49)

RFC6062: Implement client side for TCP allocations
#### Description
Builds off of https://github.com/pion/turn/pull/148

Implements client side support for TCP allocations.
I'm also planning on adding support to server side

For the TCP allocation implementation, I tried follow the pattern of creating a client, calling allocate, and then using that allocation for communication. I thought that instead of calling WriteTo() and ReadFrom() on a TCP allocation, it would be better to Accept and Dial, and return a net.Conn for the end user to use, so I ended up having to do some refactoring.

Any feedback is welcome!


#### Usage:
1. Start coturn or some other turn server 
`./bin/turnserver --lt-cred-mech -u andy:secret -r pion.ly --no-cli ./examples/ca/turn_server_cert.pem --pkey ./examples/ca/turn_server_pkey.pem --allow-loopback-peers`

2. Run tcp client, also starting up a simple tcp server to communicate relay IPs.
`./tcp_alloc --host 127.0.0.1 --user andy=secret -server` 

3. Run another tcp client, which gets the relay IP from the other client and sends its own.
`./tcp_alloc --host 127.0.0.1 --user andy=secret`
 
Both clients should read and write a single message.


#### Reference issue
https://github.com/pion/turn/issues/143, https://github.com/pion/turn/issues/118


rg0now: Thanks for the PR, I'm loving it! I also wanted to take up #148 myself, I'm very happy that you came forward to do that.

Quick question: what is the purpose of the new `Protocol` field in the `ClientConfig`? My initial guess was that this was to fix the client exclusively to RFC6062-TCP allocations, but then why do you introduce an `AllocateTCP` function for a seemingly similar purpose? I mean, what's the difference between calling `Allocate` on a client with `client.Protocol="tcp"` and calling `AllocateTCP` on a client with `client.Protocol="udp"`? Then it occurred to me that most probably you introduce this in order to drive the client in resolving the TURN/STUN server addresses, but this is not needed: RFC6062-TCP allocations get you a connection to a TCP *peer* and not a TCP STUN/TURN *server*. 

My suggestion would be to remove `ClientConfig.Protocol`  and let the user specify the peer protocol by calling either `Allocate` or `AllocateTCP`. Also, you won't need a separate UDP/TCP resolving round for `stunServ` and `turnServ`, just use `ResolveUDPAddr`, this is what we have used so far anyway.

I'll try to do a formal review later too. And thanks again for picking this PR up, it is really useful!
AndyLc: > Thanks for the PR, I'm loving it! I also wanted to take up #148 myself, I'm very happy that you came forward to do that.
> 
> Quick question: what is the purpose of the new `Protocol` field in the `ClientConfig`? My initial guess was that this was to fix the client exclusively to RFC6062-TCP allocations, but then why do you introduce an `AllocateTCP` function for a seemingly similar purpose? I mean, what's the difference between calling `Allocate` on a client with `client.Protocol="tcp"` and calling `AllocateTCP` on a client with `client.Protocol="udp"`? Then it occurred to me that most probably you introduce this in order to drive the client in resolving the TURN/STUN server addresses, but this is not needed: RFC6062-TCP allocations get you a connection to a TCP _peer_ and not a TCP STUN/TURN _server_.
> 
> My suggestion would be to remove `ClientConfig.Protocol` and let the user specify the peer protocol by calling either `Allocate` or `AllocateTCP`. Also, you won't need a separate UDP/TCP resolving round for `stunServ` and `turnServ`, just use `ResolveUDPAddr`, this is what we have used so far anyway.
> 
> I'll try to do a formal review later too. And thanks again for picking this PR up, it is really useful!

Thank you for looking at the PR! I agree, specifying Protocol in the ClientConfig and having both AllocateTCP and AllocateUDP is redundant.

I'll update the PR with those changes.
rg0now: > it would be better to Accept and Dial, and return a net.Conn for the end user to use, so I ended up having to do some refactoring.

I'm thinking about the semantics of the thingie that is supposed to be returned from `AllocateTCP`:
- on the one hand it should be a proper `net.Dialer` so that the client can call `Dial` or `DialContext` to create a new connection to a peer
- on the other hand it also must implement the `net.Listener` interface so that we can call `Accept` on it to handle the cases when a peer connects to our transport relay connection on the server.

Wdyt?
AndyLc: I think that makes a lot of sense. What I return right now supports just Dial() and Accept()

Thinking about making it implement net.Listener, since `net.Listener`'s `Accept()` returns `(Conn, error)`, if I wanted to know the address of the peer that is trying to connect to me, calling RemoteAddr() will give me the address of the TURN server, not the peer. We would need to create a new struct which implements net.Conn that returns the correct RemoteAddr().

As for net.Dialer, what are you thinking for making it a net.Dialer but also implementing net.Listener?
rg0now: > Thinking about making it implement net.Listener, since net.Listener's Accept() returns (Conn, error), if I wanted to know the address of the peer that is trying to connect to me, calling RemoteAddr() will give me the address of the TURN server, not the peer. We would need to create a new struct which implements net.Conn that returns the correct RemoteAddr().

This is very similar to how [the `LocalAddr` function of the `UDPConn`](https://github.com/pion/turn/blob/7abfa3bf2628e2869945114ce06b8c2a92b0438e/internal/client/conn.go#L304) returned by `client.Allocate` actually returns the relay transport address instead of the local address. So indeed, this is a good idea.

> As for net.Dialer, what are you thinking for making it a net.Dialer but also implementing net.Listener?

The client needs to be able to both (1) accept new connections from different peers and (2) make new connections to different peers.

This is simple for UDP that is unconnected: we have a single `Allocate` returning a "universal" `UDPConn` that you can use to send to *any* peer by specifying the peer address in the `WriteTo`, and to receive packets from any peer by using `ReadFrom`. Problem is: this does not exist with TCP since there is no `WriteTo` and `ReadFrom`, only `Write` and `Read`, as TCP is connected. So every time the client wants to make a new connection to a peer it has to create a new TCP connection via the TURN server, which is essentially the same as implementing `net.Dialer` on the thing returned by `AllocateTCP`, and every time it wants to accept new connections from a peer it has to make a new connection again, which maps nicely to `net.Listener`.

So what I'm saying is that, for a client to accept new connections from a peer it should call `Accept` on the thing returned by `AllocateTCP`:

```
stuff, err := client.AllocateTCP()

conn, err := stuff.Accept()
```

While for making a new connection to a peer it has to call `Dial`:

```
stuff, err := client.AllocateTCP()

conn, err := stuff.Dial("tcp", peerAddr)
```

I'm thinking about a good name for `stuff`: `transport` or `net` are the closest I can come up with right now, as per `pion/transport`...
AndyLc: Makes sense, I totally agree

> I'm thinking about a good name for `stuff`: `transport` or `net` are the closest I can come up with right now, as per `pion/transport`...

I'm open to either, maybe `socket`? It feels like we're using a socket on the allocation we made to accept to connections from peers, or dial to peers.


AndyLc: > * create a new `turn.Dialer` interface that generalizes `net.Dialer` (I can't for the love of god understand why `net.Dialer` is not an interface but anyway), which is essentially a factory to make new connections (whatever type of connections, doesn't have to be TCP):
>   ```
>   type Dialer interface {
>       Dial(network, address string) (Conn, error)
>       DialContext(ctx context.Context, network, address string) (Conn, error) // I'm not sure we need this...
>   }
>   ```
> * make a default implementation that implements `turn.Dialer` using `net`
> * allow the user to pass in their own implementation either in the `NewClient` or the `AllocateTCP` call (I'd prefer the first) but make this optional; if no `turn.Dialer` is specified then we set the default implementation that uses `net`
> * convert `TCPConn` to use the abstract `Dialer`

Sounds great, I will add this. One issue, currently the turn package imports client. `TCPConn` lives in package client, and if I were to give it a user-defined dialer, it would create create circular dependency. Shall we move turn.Dialer to a new package? Maybe utils or something?

Also for TCPRelay, you're thinking renaming `TCPConn`->`TCPRelay`? Or creating another interface, TCPRelay, which has both Dial and Accept?

rg0now: > Sounds great, I will add this. One issue, currently the turn package imports client. TCPConn lives in package client, and if I were to give it a user-defined dialer, it would create create circular dependency. Shall we move turn.Dialer to a new package? Maybe utils or something?

Hmm, good catch. My gut feeling is that it should go into `pion/transport` but I really  don't know. Let's put it into `internals/ipnet` for now but this is not optimal, I hope @stv0g will jump in and suggest something better.

> Also for TCPRelay, you're thinking renaming TCPConn->TCPRelay? Or creating another interface, TCPRelay, which has both Dial and Accept?

I'd call the new interface that is both a `net.Dialer` and a `net.Listener` as `TCPRelay`, I would put this into `internals/ipnet` or a new package `internals/net` or `internals/util` or `internals/transport`, and I would make `TCPConn` an implementation of this interface. Does this sound stupid? API-design-wise this PR ended up pretty unique: I've never seen a case when a TCP endpoint behaved both as a `net.Dialer` and a `net.Listener`.
rg0now: As per why `net.Dialer` is not an interface, see this: https://github.com/golang/go/issues/9360. I don't agree with the points made there, but anyway.
AndyLc: > let's put the `Relay` interface into a new package called `internal/transport` and then the users of it would refer to it as `transport.Relay` and that would nicely connote the meaning that this interface works in two directions, both as a Dialer and a Listener.

So I tried creating this interface
```
import (
	t "github.com/pion/transport/v2"
	"net"
)

type Relay interface {
	net.Listener
	t.Dialer
}
```
but net.Listener's Accept() only returns a Conn and error. From net.Conn, RemoteAddr() will return the address of the turn server, not the peer. Do you have any ideas on the best way to convey from which peer we are accepting a connection?

Originally I had Accept also return another address, but maybe there's a better way?

rg0now: > but net.Listener's Accept() only returns a Conn and error. From net.Conn, RemoteAddr() will return the address of the turn server, not the peer. Do you have any ideas on the best way to convey from which peer we are accepting a connection?

Bad news: you'll have to implement your own `net.TCPListener` and `net.Conn` for this. Good news, this seems simple thanks to Go's embedding. So here is my take.

Basically we need to create our own implementation of `net.Listener` called, say, `TCPRelay`, that will provide us our own `net.Conn` implementation, say, `TCPRelayConn`, on accept. 

```
type TCPRelay struct {
    net.TCPListener
    // put something here that will provide the custom remote addr for the connections created
    // on this TCPRelay, say, a turn.Allocation
}

type TCPRelayConn struct {
    net.Conn
    RemoteAddr net.Addr
}

// Implement all of net.Listener for TCPRelay, here is the Accept() for a sample
func (l *TCPRelay) Accept() (TCPRelayConn, error){
    c, err := TCPListener.Accept()
    a := <obtain the remote addr for this allocation>
    return &TCPRelayConn{c, a}, err
}

// Implement all of net.Conn for TCPRelayConn, here is RemoteAddr
func (c *TCPRelayConn) RemoteAddr() net.Addr {
    return c.RemoteAddr
}

// Implement the whole of `transport.Dialer` for TCPRelay
func (l *TCPRelay) Dial(network, address string) (TCPRelayConn, error) {
    // use DialTCP or similar and then return the TCPRelayConn that wraps the net.Conn obtained
}
```

At the end we should have a default implementation for the `Relay` interface that people can use if
they don't want to mess with overriding anything, but since your code internally only ever
interacts with the `Relay` interface it is generic enough to support people providing their own
implementation.

Looking at this right now, I'm not sure we have to take all this complexity at the first step. We
could go with your the hardcoded-TCP implementation for now, let the PRs get merged, and only after
that generalize? What do you think is the best way forward?
AndyLc: > We could go with your the hardcoded-TCP implementation for now, let the PRs get merged, and only after
> that generalize? What do you think is the best way forward?

Agreed, I think getting the PRs in and then generalizing will make the code changes simpler and more iterative. 

I'll re-push my current changes based off your suggestions now!
stv0g: Hi all, I am joining the party a bit late. So I will need some more time to review the PR. Thanks @AndyLc for starting to work on it :) And @rg0now for the first review :)

> [...] you'll have to implement your own net.TCPListener

We already have an interface for this defined here: https://github.com/pion/transport/blob/22a2f3b9fe3381cb4a2735420e05b64813ca3e4f/net.go#L378

Did you maybe already have a look on how we can plug this into pion/ice?
I think its important to keep this in mind before we merge an API which would be hard/impossible to be used by pion/ice


rg0now: You mean [`Agent.gatherCandidatesRelay`](https://github.com/pion/ice/blob/cf6f758882661476a2d9b7d6eb0a19d9ff74cab8/gather.go#L506), no?

The definite answer is in RFC 6544 ([TCP Candidates with Interactive Connectivity Establishment (ICE)](https://datatracker.ietf.org/doc/rfc6544) but I don't have to read it. I think the main idea is that we create *two* allocations on any TCP TURN server: one conventional UDP and another RFC 6062 style TCP. Then, we send two relay candidates over to the peer, one with protocol UDP and another with TCP. 

If we are at RFCs: here is what RFC 8835 (Transports for WebRTC) has to say on this:

TURN TCP candidates, where the connection from the WebRTC endpoint's TURN server to the peer is a TCP connection, [RFC6062] MAY be supported.

However, such candidates are not seen as providing any significant benefit, for the following reasons.

First, use of TURN TCP candidates would only be relevant in cases where both peers are required to use TCP to establish a connection.

Second, that use case is supported in a different way by both sides establishing UDP relay candidates using TURN over TCP to connect to their respective relay servers.

Third, using TCP between the WebRTC endpoint's TURN server and the peer may result in more performance problems than using UDP, e.g., due to head of line blocking.

AndyLc: Thanks @stv0g for taking a look!

> I think the main idea is that we create _two_ allocations on any TCP TURN server: one conventional UDP and another RFC 6062 style TCP. Then, we send two relay candidates over to the peer, one with protocol UDP and another with TCP.

If we go with this, I believe it should be pretty straightforward. In addition to calling Allocate(), we also call `AllocateTCP()` when `url.Proto == ProtoTypeTCP`. Looks like network type, which is always UDP needs to be changed too. I can take a more detailed look later.

One concern I had with this method is, I feel we should only gather TCP relay candidates under certain conditions. For example, if we ever created an offer specifying `m=audio 45664 TCP/RTP/AVP 0`, we should only generate TCP candidates right? In that case, we might need to rework `GatherCandidates()` to take in the target protocol, otherwise allow all.

stv0g: >  I feel we should only gather TCP relay candidates under certain conditions.

The ICE agent config already allows you to specify which types of candidates should be gathered via its `NetworkTypes` and `CandidateTypes` settings.
rg0now: I took a look at the client API proposed in #143. Turns out there are some important differences.

1. In order for a client to obtain a new TCP connection to a peer, in this proposal the client uses the "thing" returned from `AllocateTCP` as a Dialer:

   ```
   relayConn, _ := client.AllocateTCP()
   conn, _ = relayConn.Dial("tcp", peerAddr)
   ```

   In contrast, in #143 we first make a TCP allocation on the server, obtain a new connection id, we open another TCP connection, and finally bind the new connection to the connection id we have just obtained in a separate step:

   ```
   // not sure what to do with relayConn
   relayConn, _ := client.AllocateTCP()
   
   // attempt connection, get connection id
   cid, _ := client.Connect(peerAddr)
   
   // open new data connection to turn server
   dconn, _ := net.Dial("tcp", turnServerAddr)
   
   // associate connection with connection id
   _ = client.ConnectionBind(dconn, cid)
   ```

   I personally prefer the latter, since it allows to bind *any* `net.Conn` to the connection id, not just `net.TCPConn`. This would be fairly difficult with the API proposed here, where the `net.(TCP)Conn` is created and returned by the Dialer (see the discussion above).

2. To support the opposite case, when it is the peer that initiates the new connection, the client has to be able to call `Accept` on the thing returned by `AllocateTCP`. I don't see support for this in #143 (observe the comment "not sure what to do with relayConn" in the above snipppet, I took it literally from the PR). This PR nicely handles this by returning a thing that is also a proper `net.Listener`.

In summary, I prefer the API of #143 for making client->peer connections since it does not require us to make the thing returned from `AllocateTCP` a Dialer, and the API proposed here for making peer->client connections as it is a proper `net.Listener`. Wdyt?

@AndyLc: if we adopt this new client API then my advice to implement `t.Relay` is void. I hope it was at least a fun experience, sorry for that...:-(
AndyLc: > 1. I personally prefer the latter, since it allows to bind _any_ `net.Conn` to the connection id, not just `net.TCPConn`. This would be fairly difficult with the API proposed here, where the `net.(TCP)Conn` is created and returned by the Dialer (see the discussion above).

@rg0now Hm, is the benefit you're getting at that it would be easier to create your own implementation of net.Conn rather than transport.Dialer?

I'm also utilizing a user chosen transport.Dialer in `HandleConnectionAttempt`, where I need to create a new connection to the relay to return when the user calls Accept(). So, if I were to remove Dial from the `t.Relay`, the user would still need an implementation of Dialer if they want custom functionality.


codecov[bot]: ## [Codecov](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) Report
Patch coverage: **`45.14`**% and project coverage change: **`-1.29`** :warning:
> Comparison is base [(`7abfa3b`)](https://codecov.io/gh/pion/turn/commit/7abfa3bf2628e2869945114ce06b8c2a92b0438e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) 68.34% compared to head [(`2da1324`)](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) 67.05%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #311      +/-   ##
==========================================
- Coverage   68.34%   67.05%   -1.29%     
==========================================
  Files          39       40       +1     
  Lines        2508     2747     +239     
==========================================
+ Hits         1714     1842     +128     
- Misses        663      754      +91     
- Partials      131      151      +20     
```

| Flag | Coverage Δ | |
|---|---|---|
| go | `67.05% <45.14%> (-1.29%)` | :arrow_down: |
| wasm | `40.33% <15.52%> (-4.37%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) | Coverage Δ | |
|---|---|---|
| [internal/client/errors.go](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aW50ZXJuYWwvY2xpZW50L2Vycm9ycy5nbw==) | `0.00% <ø> (ø)` | |
| [internal/proto/reqtrans.go](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aW50ZXJuYWwvcHJvdG8vcmVxdHJhbnMuZ28=) | `92.85% <0.00%> (-7.15%)` | :arrow_down: |
| [internal/server/turn.go](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aW50ZXJuYWwvc2VydmVyL3R1cm4uZ28=) | `59.25% <0.00%> (-1.12%)` | :arrow_down: |
| [internal/client/tcp\_conn.go](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aW50ZXJuYWwvY2xpZW50L3RjcF9jb25uLmdv) | `34.44% <34.44%> (ø)` | |
| [client.go](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-Y2xpZW50Lmdv) | `68.21% <50.00%> (-4.29%)` | :arrow_down: |
| [internal/client/conn.go](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aW50ZXJuYWwvY2xpZW50L2Nvbm4uZ28=) | `62.81% <87.50%> (+0.94%)` | :arrow_up: |
| [internal/client/permission.go](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aW50ZXJuYWwvY2xpZW50L3Blcm1pc3Npb24uZ28=) | `97.43% <92.30%> (-2.57%)` | :arrow_down: |

... and [6 files with indirect coverage changes](https://codecov.io/gh/pion/turn/pull/311/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion)

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/pion/turn/pull/311?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion).

stv0g: Hi @rg0now,

> Also, can you please comment on the API? It'd be nice to get your opinion on https://github.com/pion/turn/pull/311#issuecomment-1498162747.

Sure. I had to spend some time to think about it.
Currently, I am leaning towards the API proposed by @AndyLc in this PR in favor of #143.
Mainly, because I find it hard to find a use case for binding an existing TCP connection to the connection ID.
It just makes the API harder to use as the user must establish the TCP data connection to the same TURN server address as the control connection.

But I am not against supporting also the ConnectionBind use case.

Here is my proposal which basically combines the two approaches by moving the `client. ConnectionBind()` method to `allocation.BindConnection()`:

```go
package turn

type Client struct { ... }

func (c *Client) AllocateTCP() (*TCPAllocation, error)

type TCPAllocation struct {
	// ConnectionID uniquely identifies a peer data connection.
	ConnectionID uint32

	// addr is the relayed transport address
	addr *net.TCPAddr
	client *Client

	...
}

// BindConnection associates the provided connection as the allocations data connection.
func (a *TCPAllocation) BindConnection(conn net.Conn) error

// Dial connects to the address on the named network.
func (a *TCPAllocation) Dial(network, address string) (net.Conn, error) {}

// DialContext connects to the address on the named network using the provided context.
func (a *TCPAllocation) DialContext(ctx context.Context, network, address string) (net.Conn, error)

func (a *TCPAllocation) DialTCP(network string, laddr, raddr *net.TCPAddr) (*TCPConn, error) {
	...
	a.ConnectionID = ...

	conn, err := net.DialTCP(network, client.BindConnection)
	if err != nil {
		return nil, fmt.Errorf("failed to establish connection to TURN server: %w", err)
	}

	if err := a.BindConnection(conn); err != nil {
		return nil, fmt.Errorf("failed to bind connection: %w", err)
	}

	return &TCPConn{
		TCPConn: conn,
		allocation: a,
	}, nil
}

// Accept waits for and returns the next connection to the listener.
func (a *TCPAllocation) Accept() (net.Conn, error) { ... }

// AcceptTCP accepts the next incoming call and returns the new connection.
func (a *TCPAllocation) AcceptTCP() (*TCPConn, error) { ... }

// Addr returns the allocations relayed address.
func (a *TCPAllocation) Addr() net.Addr { ... }

// Close releases the allocation
// Any blocked Accept operations will be unblocked and return errors.
// Any opened connection via Dial/Accept will be closed.
func (a *TCPAllocation) Close() error { ... }

// SetDeadline sets the deadline associated with the listener.
// A zero time value disables the deadline.
func (a *TCPAllocation) SetDeadline(t time.Time) error { ... }

// TCPConn wraps a net.TCPConn and returns the allocations relayed
// transport address in response to TCPConn.LocalAddress()
type TCPConn struct {
	*net.TCPConn

	allocation *TCPAllocation
}

func (c *TCPConn) LocalAddress() net.Addr {
	return c.allocation.Addr()
}

var _ transport.TCPListener = (*TCPAllocation)(nil) // Includes type check net.Listener
var _ transport.TCPConn = (*TCPConn)(nil) // Includes type check for net.Conn
```
stv0g: I currently see no need to define our own interface types here as I can not see anything which would need to be user customizable.

In my previous post I've choosen some new naming:

- `RelayConn` -> `TCPAllocation` 

Also, @AndyLc let me know if I can support you with the implementation :)
rg0now: I really like this API: clean and simple.

```go
var _ transport.TCPListener = (*TCPAllocation)(nil) // Includes type check for net.Listener
var _ transport.TCPConn = (*TCPConn)(nil) // Includes type check for net.Conn
var _ transport.Dialer = (*TCPAllocation)(nil)
```

Huhh, it didn't occur to me that you could do this in Go, nice! I guess we want to put this into the tests, no?

@AndyLc is there anything I can help you to get this PR in a mergeable form? I'd really love to have this feature available in pion/turn. Also, sorry for making you run these unnecessary circles with `net.Relay`, I hope it was not completely useless 
AndyLc: Hey guys, I made some modifications to move towards @stv0g's proposal. There were some differences like 1. I think BindConnection needs to take in a ConnectionID, the reason being that under the same allocation, you can have multiple connections and thus multiple data connections w/ different connection IDs. So when you call BindConnection, you will need to specify which connection to bind.

Also, the CreatePermission in client.go may need to be reworked to specify which allocation to create a permission on, TCP or UDP. As it is now, it tries to create permissions on both. What would you guys prefer?

I also was unclear where to add these
``
`var _ transport.TCPListener = (*TCPAllocation)(nil) // Includes type check for net.Listener
var _ transport.TCPConn = (*TCPConn)(nil) // Includes type check for net.Conn
var _ transport.Dialer = (*TCPAllocation)(nil)
```
I assumed it was a type check so I just put it at the top of tcp_conn.go


stv0g: > I think `BindConnection` needs to take in a `ConnectionID`, the reason being that under the same allocation, you can have multiple connections and thus multiple data connections w/ different connection IDs. So when you call `BindConnection`, you will need to specify which connection to bind.

Oh, okay that is a detail which I missed. But it makes total sense.. Everytime, we `Accept` we basically get a new connection id. I've put some more thought into the API and updated my previous post with an updated API proposal.

The main change is the removal of `TCPAllocation.BindConnection()`. I've replaced it with additional methods:

- `TCPAllocation.DialTCPWithConn(conn net.Conn, network string, laddr, raddr *net.TCPAddr) (*TCPConn, error)`
- `TCPAllocation.AcceptTCPWithConn(conn net.Conn) (*TCPConn, error)`

Furthermore, the `ConnectionID` member moved from the `TCPAllocation` to `TCPConn` type.

> I also was unclear where to add these [...]

I would add those to the same place where the types which are checked are declared.
stv0g: The beauty of this API is, that basically `TCPAllocation.DialTCP()` is just thin wrapper around `TCPAllocation.DialTCPWithConn()`.
AndyLc: Ah, that makes a lot of sense, I like it. Do you think it's necessary at the moment to have DialTCP or just Dial? If we want DialTCP it seems we'll have to have a TCPDialer in pion/transport
stv0g: > Do you think it's necessary at the moment to have DialTCP or just Dial?

I think we could start with `Dial` first and add `DialTCP` lateron if needed.
AndyLc: Cool, I added those changes, let me know what you think! There are some small differences
stv0g: Hi @AndyLc,

thanks for the fantastic work :-) I've reviewed your code and have some ideas for refactoring `RelayConnContext`.
I am sorry, that we are going through these rounds. This really hasnt been because of bad design decissions by you, but rather previous contributors.. We must refactor some of the naming the the internal client package.
stv0g: Please let me know if I can help you with it :) I would be happy to also push changes to your branch.
AndyLc: @stv0g Yes feel free to make any changes you want! I just added you to the repo. 
stv0g: @AndyLc thanks for the repo access. I've addressed my remaining comments myself and also rebased your `dev` to the upstream `master`.

**Please reset your local branch as it gets messed up otherwise due to the rebase.**

Lets, see what the CI says..
AndyLc: @stv0g Thanks for making edits! I saw that some tests were still failing, so I pushed another commit to address those, mainly fixing golangci-lint issues and adding more unit tests.
stv0g: Hi @AndyLc :)

Great! I think we are getting close merging this into master 🥳 

I currently, see only one thing missing: We should add support for `pion/transport.Net` to the `Client` as use `net.Dial` for creating the TCP data-connection. Right now we are limited to the standard `net.Dial`. By using the interface from `pion/transport` we will gain the ability to test the feature with `pion/transport/vnet` as soon as it gains TCP support.
AndyLc: @stv0g Sounds good, I attempted to add vnet, let me know what you think!
stv0g: Hi @AndyLc,

cool :) I've found that we had two implementations of a client mock (`dummyConnObverver` and `dummyClient`). I refactored this into `mockClient`.

I also simplified this mock client by moving more of the static configuration attributes to `AllocationConfig`.
I've done so to keep the changes to the API surface of `turn.Client` to a minimum.
AndyLc: @stv0g Looks like I don't have access to merge the PR `Only those with [write access](https://docs.github.com/articles/what-are-the-different-access-permissions) to this repository can merge pull requests.` Your changes look good to me though!

Patch:
diff --git a/AUTHORS.txt b/AUTHORS.txt
index 6ebc9d8..75267d5 100644
--- a/AUTHORS.txt
+++ b/AUTHORS.txt
@@ -7,6 +7,7 @@ Aaron France <aaron.l.france@gmail.com>
 Aleksandr Razumov <ar@gortc.io>
 anastasia <anastasia@plutoview.com>
 andrefsp <andrefsp@gmail.com>
+Andy Yan <yan.andy4@gmail.com>
 Antonio Sorrentino <ansorrenltd@gmail.com>
 Artem Yarovenko <a.yarovenko@rubetek.com>
 Atsushi Watanabe <atsushi.w@ieee.org>
diff --git a/client.go b/client.go
index df3cd36..1016174 100644
--- a/client.go
+++ b/client.go
@@ -13,6 +13,8 @@ import (
 
 	"github.com/pion/logging"
 	"github.com/pion/stun"
+	"github.com/pion/transport/v2"
+	"github.com/pion/transport/v2/stdnet"
 	"github.com/pion/turn/v2/internal/client"
 	"github.com/pion/turn/v2/internal/proto"
 )
@@ -43,12 +45,14 @@ type ClientConfig struct {
 	Software       string
 	RTO            time.Duration
 	Conn           net.PacketConn // Listening socket (net.PacketConn)
+	Net            transport.Net
 	LoggerFactory  logging.LoggerFactory
 }
 
 // Client is a STUN server client
 type Client struct {
 	conn           net.PacketConn // Read-only
+	net            transport.Net  // Read-only
 	stunServerAddr net.Addr       // Read-only
 	turnServerAddr net.Addr       // Read-only
 
@@ -60,11 +64,12 @@ type Client struct {
 	trMap         *client.TransactionMap // Thread-safe
 	rto           time.Duration          // Read-only
 	relayedConn   *client.UDPConn        // Protected by mutex ***
+	tcpAllocation *client.TCPAllocation  // Protected by mutex ***
 	allocTryLock  client.TryLock         // Thread-safe
 	listenTryLock client.TryLock         // Thread-safe
 	mutex         sync.RWMutex           // Thread-safe
 	mutexTrMap    sync.Mutex             // Thread-safe
-	log           logging.LeveledLogger  // Tead-only
+	log           logging.LeveledLogger  // Read-only
 }
 
 // NewClient returns a new Client instance. listeningAddress is the address and port to listen on, default "0.0.0.0:0"
@@ -85,6 +90,14 @@ func NewClient(config *ClientConfig) (*Client, error) {
 		rto = config.RTO
 	}
 
+	if config.Net == nil {
+		n, err := stdnet.NewNet()
+		if err != nil {
+			return nil, err
+		}
+		config.Net = n
+	}
+
 	c := &Client{
 		conn:           config.Conn,
 		stunServerAddr: config.STUNServerAddr,
@@ -94,6 +107,7 @@ func NewClient(config *ClientConfig) (*Client, error) {
 		realm:          stun.NewRealm(config.Realm),
 		software:       stun.NewSoftware(config.Software),
 		trMap:          client.NewTransactionMap(),
+		net:            config.Net,
 		rto:            rto,
 		log:            log,
 	}
@@ -201,42 +215,34 @@ func (c *Client) SendBindingRequest() (net.Addr, error) {
 	return c.SendBindingRequestTo(c.stunServerAddr)
 }
 
-// Allocate sends a TURN allocation request to the given transport address
-func (c *Client) Allocate() (net.PacketConn, error) {
-	if err := c.allocTryLock.Lock(); err != nil {
-		return nil, fmt.Errorf("%w: %s", errOneAllocateOnly, err.Error())
-	}
-	defer c.allocTryLock.Unlock()
-
-	relayedConn := c.relayedUDPConn()
-	if relayedConn != nil {
-		return nil, fmt.Errorf("%w: %s", errAlreadyAllocated, relayedConn.LocalAddr().String())
-	}
+func (c *Client) sendAllocateRequest(protocol proto.Protocol) (proto.RelayedAddress, proto.Lifetime, stun.Nonce, error) {
+	var relayed proto.RelayedAddress
+	var lifetime proto.Lifetime
+	var nonce stun.Nonce
 
 	msg, err := stun.Build(
 		stun.TransactionID,
 		stun.NewType(stun.MethodAllocate, stun.ClassRequest),
-		proto.RequestedTransport{Protocol: proto.ProtoUDP},
+		proto.RequestedTransport{Protocol: protocol},
 		stun.Fingerprint,
 	)
 	if err != nil {
-		return nil, err
+		return relayed, lifetime, nonce, err
 	}
 
 	trRes, err := c.PerformTransaction(msg, c.turnServerAddr, false)
 	if err != nil {
-		return nil, err
+		return relayed, lifetime, nonce, err
 	}
 
 	res := trRes.Msg
 
 	// Anonymous allocate failed, trying to authenticate.
-	var nonce stun.Nonce
 	if err = nonce.GetFrom(res); err != nil {
-		return nil, err
+		return relayed, lifetime, nonce, err
 	}
 	if err = c.realm.GetFrom(res); err != nil {
-		return nil, err
+		return relayed, lifetime, nonce, err
 	}
 	c.realm = append([]byte(nil), c.realm...)
 	c.integrity = stun.NewLongTermIntegrity(
@@ -246,7 +252,7 @@ func (c *Client) Allocate() (net.PacketConn, error) {
 	msg, err = stun.Build(
 		stun.TransactionID,
 		stun.NewType(stun.MethodAllocate, stun.ClassRequest),
-		proto.RequestedTransport{Protocol: proto.ProtoUDP},
+		proto.RequestedTransport{Protocol: protocol},
 		&c.username,
 		&c.realm,
 		&nonce,
@@ -254,57 +260,129 @@ func (c *Client) Allocate() (net.PacketConn, error) {
 		stun.Fingerprint,
 	)
 	if err != nil {
-		return nil, err
+		return relayed, lifetime, nonce, err
 	}
 
 	trRes, err = c.PerformTransaction(msg, c.turnServerAddr, false)
 	if err != nil {
-		return nil, err
+		return relayed, lifetime, nonce, err
 	}
 	res = trRes.Msg
 
 	if res.Type.Class == stun.ClassErrorResponse {
 		var code stun.ErrorCodeAttribute
 		if err = code.GetFrom(res); err == nil {
-			return nil, fmt.Errorf("%s (error %s)", res.Type, code) //nolint:goerr113
+			return relayed, lifetime, nonce, fmt.Errorf("%s (error %s)", res.Type, code) //nolint:goerr113
 		}
-		return nil, fmt.Errorf("%s", res.Type) //nolint:goerr113
+		return relayed, lifetime, nonce, fmt.Errorf("%s", res.Type) //nolint:goerr113
 	}
 
 	// Getting relayed addresses from response.
-	var relayed proto.RelayedAddress
 	if err := relayed.GetFrom(res); err != nil {
+		return relayed, lifetime, nonce, err
+	}
+
+	// Getting lifetime from response
+	if err := lifetime.GetFrom(res); err != nil {
+		return relayed, lifetime, nonce, err
+	}
+	return relayed, lifetime, nonce, nil
+}
+
+// Allocate sends a TURN allocation request to the given transport address
+func (c *Client) Allocate() (net.PacketConn, error) {
+	if err := c.allocTryLock.Lock(); err != nil {
+		return nil, fmt.Errorf("%w: %s", errOneAllocateOnly, err.Error())
+	}
+	defer c.allocTryLock.Unlock()
+
+	relayedConn := c.relayedUDPConn()
+	if relayedConn != nil {
+		return nil, fmt.Errorf("%w: %s", errAlreadyAllocated, relayedConn.LocalAddr().String())
+	}
+
+	relayed, lifetime, nonce, err := c.sendAllocateRequest(proto.ProtoUDP)
+	if err != nil {
 		return nil, err
 	}
+
 	relayedAddr := &net.UDPAddr{
 		IP:   relayed.IP,
 		Port: relayed.Port,
 	}
 
-	// Getting lifetime from response
-	var lifetime proto.Lifetime
-	if err := lifetime.GetFrom(res); err != nil {
+	relayedConn = client.NewUDPConn(&client.AllocationConfig{
+		Client:      c,
+		RelayedAddr: relayedAddr,
+		ServerAddr:  c.turnServerAddr,
+		Realm:       c.realm,
+		Username:    c.username,
+		Integrity:   c.integrity,
+		Nonce:       nonce,
+		Lifetime:    lifetime.Duration,
+		Net:         c.net,
+		Log:         c.log,
+	})
+	c.setRelayedUDPConn(relayedConn)
+
+	return relayedConn, nil
+}
+
+// AllocateTCP creates a new TCP allocation at the TURN server.
+func (c *Client) AllocateTCP() (*client.TCPAllocation, error) {
+	if err := c.allocTryLock.Lock(); err != nil {
+		return nil, fmt.Errorf("%w: %s", errOneAllocateOnly, err.Error())
+	}
+	defer c.allocTryLock.Unlock()
+
+	allocation := c.getTCPAllocation()
+	if allocation != nil {
+		return nil, fmt.Errorf("%w: %s", errAlreadyAllocated, allocation.Addr())
+	}
+
+	relayed, lifetime, nonce, err := c.sendAllocateRequest(proto.ProtoTCP)
+	if err != nil {
 		return nil, err
 	}
 
-	relayedConn = client.NewUDPConn(&client.UDPConnConfig{
-		Observer:    c,
+	relayedAddr := &net.TCPAddr{
+		IP:   relayed.IP,
+		Port: relayed.Port,
+	}
+
+	allocation = client.NewTCPAllocation(&client.AllocationConfig{
+		Client:      c,
 		RelayedAddr: relayedAddr,
+		ServerAddr:  c.turnServerAddr,
+		Realm:       c.realm,
+		Username:    c.username,
 		Integrity:   c.integrity,
 		Nonce:       nonce,
 		Lifetime:    lifetime.Duration,
+		Net:         c.net,
 		Log:         c.log,
 	})
 
-	c.setRelayedUDPConn(relayedConn)
+	c.setTCPAllocation(allocation)
 
-	return relayedConn, nil
+	return allocation, nil
 }
 
 // CreatePermission Issues a CreatePermission request for the supplied addresses
 // as described in https://datatracker.ietf.org/doc/html/rfc5766#section-9
 func (c *Client) CreatePermission(addrs ...net.Addr) error {
-	return c.relayedUDPConn().CreatePermissions(addrs...)
+	if conn := c.relayedUDPConn(); conn != nil {
+		if err := conn.CreatePermissions(addrs...); err != nil {
+			return err
+		}
+	}
+
+	if allocation := c.getTCPAllocation(); allocation != nil {
+		if err := allocation.CreatePermissions(addrs...); err != nil {
+			return err
+		}
+	}
+	return nil
 }
 
 // PerformTransaction performs STUN transaction
@@ -350,6 +428,7 @@ func (c *Client) PerformTransaction(msg *stun.Message, to net.Addr, ignoreResult
 // (Called by UDPConn)
 func (c *Client) OnDeallocated(net.Addr) {
 	c.setRelayedUDPConn(nil)
+	c.setTCPAllocation(nil)
 }
 
 // HandleInbound handles data received.
@@ -408,7 +487,8 @@ func (c *Client) handleSTUNMessage(data []byte, from net.Addr) error {
 	}
 
 	if msg.Type.Class == stun.ClassIndication {
-		if msg.Type.Method == stun.MethodData {
+		switch msg.Type.Method {
+		case stun.MethodData:
 			var peerAddr proto.PeerAddress
 			if err := peerAddr.GetFrom(msg); err != nil {
 				return err
@@ -430,8 +510,34 @@ func (c *Client) handleSTUNMessage(data []byte, from net.Addr) error {
 				c.log.Debug("no relayed conn allocated")
 				return nil // Silently discard
 			}
-
 			relayedConn.HandleInbound(data, from)
+		case stun.MethodConnectionAttempt:
+			var peerAddr proto.PeerAddress
+			if err := peerAddr.GetFrom(msg); err != nil {
+				return err
+			}
+
+			addr := &net.TCPAddr{
+				IP:   peerAddr.IP,
+				Port: peerAddr.Port,
+			}
+
+			var cid proto.ConnectionID
+			if err := cid.GetFrom(msg); err != nil {
+				return err
+			}
+
+			c.log.Debugf("connection attempt from %s", addr.String())
+
+			allocation := c.getTCPAllocation()
+			if allocation == nil {
+				c.log.Debug("no TCP allocation exists")
+				return nil // Silently discard
+			}
+
+			allocation.HandleConnectionAttempt(addr, cid)
+		default:
+			c.log.Debug("received unsupported STUN method")
 		}
 		return nil
 	}
@@ -542,3 +648,17 @@ func (c *Client) relayedUDPConn() *client.UDPConn {
 
 	return c.relayedConn
 }
+
+func (c *Client) setTCPAllocation(alloc *client.TCPAllocation) {
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
+	c.tcpAllocation = alloc
+}
+
+func (c *Client) getTCPAllocation() *client.TCPAllocation {
+	c.mutex.RLock()
+	defer c.mutex.RUnlock()
+
+	return c.tcpAllocation
+}
diff --git a/examples/turn-client/tcp-alloc/main.go b/examples/turn-client/tcp-alloc/main.go
new file mode 100644
index 0000000..752cfa5
--- /dev/null
+++ b/examples/turn-client/tcp-alloc/main.go
@@ -0,0 +1,195 @@
+// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
+// SPDX-License-Identifier: MIT
+
+// Package main implements a TURN client with support for TCP
+package main
+
+import (
+	"bufio"
+	"flag"
+	"fmt"
+	"log"
+	"net"
+	"strings"
+
+	"github.com/pion/logging"
+	"github.com/pion/turn/v2"
+)
+
+func setupSignalingChannel(addrCh chan string, signaling bool, relayAddr string) {
+	addr := "127.0.0.1:5000"
+	if signaling {
+		go func() {
+			listener, err := net.Listen("tcp", addr)
+			if err != nil {
+				log.Panicf("Failed to create signaling server: %s", err)
+			}
+			defer listener.Close() //nolint:errcheck,gosec
+			for {
+				conn, err := listener.Accept()
+				if err != nil {
+					log.Panicf("Failed to accept: %s", err)
+				}
+
+				go func() {
+					var message string
+					message, err = bufio.NewReader(conn).ReadString('\n')
+					if err != nil {
+						log.Panicf("Failed to read from relayAddr: %s", err)
+					}
+					addrCh <- message[:len(message)-1]
+				}()
+
+				if _, err = conn.Write([]byte(fmt.Sprintf("%s\n", relayAddr))); err != nil {
+					log.Panicf("Failed to write relayAddr: %s", err)
+				}
+			}
+		}()
+	} else {
+		conn, err := net.Dial("tcp", addr)
+		if err != nil {
+			log.Panicf("Error dialing: %s", err)
+		}
+		message, err := bufio.NewReader(conn).ReadString('\n')
+		if err != nil {
+			log.Panicf("Failed to read relayAddr: %s", err)
+		}
+		addrCh <- message[:len(message)-1]
+		if _, err = conn.Write([]byte(fmt.Sprintf("%s\n", relayAddr))); err != nil {
+			log.Panicf("Failed to write relayAddr: %s", err)
+		}
+	}
+}
+
+func main() {
+	host := flag.String("host", "", "TURN Server name.")
+	port := flag.Int("port", 3478, "Listening port.")
+	user := flag.String("user", "", "A pair of username and password (e.g. \"user=pass\")")
+	realm := flag.String("realm", "pion.ly", "Realm (defaults to \"pion.ly\")")
+	signaling := flag.Bool("signaling", false, "Whether to start signaling server otherwise connect")
+
+	flag.Parse()
+
+	if len(*host) == 0 {
+		log.Panicf("'host' is required")
+	}
+
+	if len(*user) == 0 {
+		log.Panicf("'user' is required")
+	}
+
+	// Dial TURN Server
+	turnServerAddrStr := fmt.Sprintf("%s:%d", *host, *port)
+
+	turnServerAddr, err := net.ResolveTCPAddr("tcp", turnServerAddrStr)
+	if err != nil {
+		log.Panicf("Failed to resolve TURN server address: %s", err)
+	}
+
+	conn, err := net.DialTCP("tcp", nil, turnServerAddr)
+	if err != nil {
+		log.Panicf("Failed to connect to TURN server: %s", err)
+	}
+
+	cred := strings.SplitN(*user, "=", 2)
+
+	// Start a new TURN Client and wrap our net.Conn in a STUNConn
+	// This allows us to simulate datagram based communication over a net.Conn
+	cfg := &turn.ClientConfig{
+		STUNServerAddr: turnServerAddr,
+		TURNServerAddr: turnServerAddr,
+		Conn:           turn.NewSTUNConn(conn),
+		Username:       cred[0],
+		Password:       cred[1],
+		Realm:          *realm,
+		LoggerFactory:  logging.NewDefaultLoggerFactory(),
+	}
+
+	client, err := turn.NewClient(cfg)
+	if err != nil {
+		log.Panicf("Failed to create TURN client: %s", err)
+	}
+	defer client.Close()
+
+	// Start listening on the conn provided.
+	err = client.Listen()
+	if err != nil {
+		log.Panicf("Failed to listen: %s", err)
+	}
+
+	// Allocate a relay socket on the TURN server. On success, it
+	// will return a client.TCPAllocation which represents the remote
+	// socket.
+	allocation, err := client.AllocateTCP()
+	if err != nil {
+		log.Panicf("Failed to allocate: %s", err)
+	}
+	defer func() {
+		if closeErr := allocation.Close(); closeErr != nil {
+			log.Panicf("Failed to close connection: %s", closeErr)
+		}
+	}()
+
+	log.Printf("relayed-address=%s", allocation.Addr())
+
+	// Learn the peers relay address via signaling channel
+	addrCh := make(chan string, 5)
+	setupSignalingChannel(addrCh, *signaling, allocation.Addr().String())
+
+	// Get peer address
+	peerAddrStr := <-addrCh
+	peerAddr, err := net.ResolveTCPAddr("tcp", peerAddrStr)
+	if err != nil {
+		log.Panicf("Failed to resolve peer address: %s", err)
+	}
+
+	log.Printf("Received peer address: %s", peerAddrStr)
+
+	buf := make([]byte, 4096)
+	var n int
+	if *signaling {
+		conn, err := allocation.DialTCP("tcp", nil, peerAddr)
+		if err != nil {
+			log.Panicf("Failed to dial: %s", err)
+		}
+
+		if _, err = conn.Write([]byte("hello!")); err != nil {
+			log.Panicf("Failed to write: %s", err)
+		}
+
+		n, err = conn.Read(buf)
+		if err != nil {
+			log.Panicf("Failed to read from relay connection: %s", err)
+		}
+
+		if err := conn.Close(); err != nil {
+			log.Panicf("Failed to close: %s", err)
+		}
+	} else {
+		if err := client.CreatePermission(peerAddr); err != nil {
+			log.Panicf("Failed to create permission: %s", err)
+		}
+
+		conn, err := allocation.AcceptTCP()
+		if err != nil {
+			log.Panicf("Failed to accept TCP connection: %s", err)
+		}
+
+		log.Printf("Accepted connection from: %s", conn.RemoteAddr())
+
+		n, err = conn.Read(buf)
+		if err != nil {
+			log.Panicf("Failed to read from relay conn: %s", err)
+		}
+
+		if _, err := conn.Write([]byte("hello back!")); err != nil {
+			log.Panicf("Failed to write: %s", err)
+		}
+
+		if err := conn.Close(); err != nil {
+			log.Panicf("Failed to close: %s", err)
+		}
+	}
+
+	log.Printf("Read message: %s", string(buf[:n]))
+}
diff --git a/internal/allocation/allocation.go b/internal/allocation/allocation.go
index 2568cbd..d68eb95 100644
--- a/internal/allocation/allocation.go
+++ b/internal/allocation/allocation.go
@@ -44,16 +44,6 @@ type Allocation struct {
 	responseCache atomic.Value // *allocationResponse
 }
 
-func addr2IPFingerprint(addr net.Addr) string {
-	switch a := addr.(type) {
-	case *net.UDPAddr:
-		return a.IP.String()
-	case *net.TCPAddr: // Do we really need this case?
-		return a.IP.String()
-	}
-	return "" // Should never happen
-}
-
 // NewAllocation creates a new instance of NewAllocation.
 func NewAllocation(turnSocket net.PacketConn, fiveTuple *FiveTuple, log logging.LeveledLogger) *Allocation {
 	return &Allocation{
@@ -70,12 +60,12 @@ func (a *Allocation) GetPermission(addr net.Addr) *Permission {
 	a.permissionsLock.RLock()
 	defer a.permissionsLock.RUnlock()
 
-	return a.permissions[addr2IPFingerprint(addr)]
+	return a.permissions[ipnet.FingerprintAddr(addr)]
 }
 
 // AddPermission adds a new permission to the allocation
 func (a *Allocation) AddPermission(p *Permission) {
-	fingerprint := addr2IPFingerprint(p.Addr)
+	fingerprint := ipnet.FingerprintAddr(p.Addr)
 
 	a.permissionsLock.RLock()
 	existedPermission, ok := a.permissions[fingerprint]
@@ -98,7 +88,7 @@ func (a *Allocation) AddPermission(p *Permission) {
 func (a *Allocation) RemovePermission(addr net.Addr) {
 	a.permissionsLock.Lock()
 	defer a.permissionsLock.Unlock()
-	delete(a.permissions, addr2IPFingerprint(addr))
+	delete(a.permissions, ipnet.FingerprintAddr(addr))
 }
 
 // AddChannelBind adds a new ChannelBind to the allocation, it also updates the
diff --git a/internal/client/allocation.go b/internal/client/allocation.go
new file mode 100644
index 0000000..2a4118d
--- /dev/null
+++ b/internal/client/allocation.go
@@ -0,0 +1,189 @@
+// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
+// SPDX-License-Identifier: MIT
+
+package client
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"sync"
+	"time"
+
+	"github.com/pion/logging"
+	"github.com/pion/stun"
+	"github.com/pion/transport/v2"
+	"github.com/pion/turn/v2/internal/proto"
+)
+
+// AllocationConfig is a set of configuration params use by NewUDPConn and NewTCPAllocation
+type AllocationConfig struct {
+	Client      Client
+	RelayedAddr net.Addr
+	ServerAddr  net.Addr
+	Integrity   stun.MessageIntegrity
+	Nonce       stun.Nonce
+	Username    stun.Username
+	Realm       stun.Realm
+	Lifetime    time.Duration
+	Net         transport.Net
+	Log         logging.LeveledLogger
+}
+
+type allocation struct {
+	client            Client                // Read-only
+	relayedAddr       net.Addr              // Read-only
+	serverAddr        net.Addr              // Read-only
+	permMap           *permissionMap        // Thread-safe
+	integrity         stun.MessageIntegrity // Read-only
+	username          stun.Username         // Read-only
+	realm             stun.Realm            // Read-only
+	_nonce            stun.Nonce            // Needs mutex x
+	_lifetime         time.Duration         // Needs mutex x
+	net               transport.Net         // Thread-safe
+	refreshAllocTimer *PeriodicTimer        // Thread-safe
+	refreshPermsTimer *PeriodicTimer        // Thread-safe
+	readTimer         *time.Timer           // Thread-safe
+	mutex             sync.RWMutex          // Thread-safe
+	log               logging.LeveledLogger // Read-only
+}
+
+func (a *allocation) setNonceFromMsg(msg *stun.Message) {
+	// Update nonce
+	var nonce stun.Nonce
+	if err := nonce.GetFrom(msg); err == nil {
+		a.setNonce(nonce)
+		a.log.Debug("refresh allocation: 438, got new nonce.")
+	} else {
+		a.log.Warn("refresh allocation: 438 but no nonce.")
+	}
+}
+
+func (a *allocation) refreshAllocation(lifetime time.Duration, dontWait bool) error {
+	msg, err := stun.Build(
+		stun.TransactionID,
+		stun.NewType(stun.MethodRefresh, stun.ClassRequest),
+		proto.Lifetime{Duration: lifetime},
+		a.username,
+		a.realm,
+		a.nonce(),
+		a.integrity,
+		stun.Fingerprint,
+	)
+	if err != nil {
+		return fmt.Errorf("%w: %s", errFailedToBuildRefreshRequest, err.Error())
+	}
+
+	a.log.Debugf("send refresh request (dontWait=%v)", dontWait)
+	trRes, err := a.client.PerformTransaction(msg, a.serverAddr, dontWait)
+	if err != nil {
+		return fmt.Errorf("%w: %s", errFailedToRefreshAllocation, err.Error())
+	}
+
+	if dontWait {
+		a.log.Debug("refresh request sent")
+		return nil
+	}
+
+	a.log.Debug("refresh request sent, and waiting response")
+
+	res := trRes.Msg
+	if res.Type.Class == stun.ClassErrorResponse {
+		var code stun.ErrorCodeAttribute
+		if err = code.GetFrom(res); err == nil {
+			if code.Code == stun.CodeStaleNonce {
+				a.setNonceFromMsg(res)
+				return errTryAgain
+			}
+			return err
+		}
+		return fmt.Errorf("%s", res.Type) //nolint:goerr113
+	}
+
+	// Getting lifetime from response
+	var updatedLifetime proto.Lifetime
+	if err := updatedLifetime.GetFrom(res); err != nil {
+		return fmt.Errorf("%w: %s", errFailedToGetLifetime, err.Error())
+	}
+
+	a.setLifetime(updatedLifetime.Duration)
+	a.log.Debugf("updated lifetime: %d seconds", int(a.lifetime().Seconds()))
+	return nil
+}
+
+func (a *allocation) refreshPermissions() error {
+	addrs := a.permMap.addrs()
+	if len(addrs) == 0 {
+		a.log.Debug("no permission to refresh")
+		return nil
+	}
+	if err := a.CreatePermissions(addrs...); err != nil {
+		if errors.Is(err, errTryAgain) {
+			return errTryAgain
+		}
+		a.log.Errorf("fail to refresh permissions: %s", err.Error())
+		return err
+	}
+	a.log.Debug("refresh permissions successful")
+	return nil
+}
+
+func (a *allocation) onRefreshTimers(id int) {
+	a.log.Debugf("refresh timer %d expired", id)
+	switch id {
+	case timerIDRefreshAlloc:
+		var err error
+		lifetime := a.lifetime()
+		// Limit the max retries on errTryAgain to 3
+		// when stale nonce returns, sencond retry should succeed
+		for i := 0; i < maxRetryAttempts; i++ {
+			err = a.refreshAllocation(lifetime, false)
+			if !errors.Is(err, errTryAgain) {
+				break
+			}
+		}
+		if err != nil {
+			a.log.Warnf("refresh allocation failed")
+		}
+	case timerIDRefreshPerms:
+		var err error
+		for i := 0; i < maxRetryAttempts; i++ {
+			err = a.refreshPermissions()
+			if !errors.Is(err, errTryAgain) {
+				break
+			}
+		}
+		if err != nil {
+			a.log.Warnf("refresh permissions failed")
+		}
+	}
+}
+
+func (a *allocation) nonce() stun.Nonce {
+	a.mutex.RLock()
+	defer a.mutex.RUnlock()
+
+	return a._nonce
+}
+
+func (a *allocation) setNonce(nonce stun.Nonce) {
+	a.mutex.Lock()
+	defer a.mutex.Unlock()
+
+	a.log.Debugf("set new nonce with %d bytes", len(nonce))
+	a._nonce = nonce
+}
+
+func (a *allocation) lifetime() time.Duration {
+	a.mutex.RLock()
+	defer a.mutex.RUnlock()
+
+	return a._lifetime
+}
+
+func (a *allocation) setLifetime(lifetime time.Duration) {
+	a.mutex.Lock()
+	defer a.mutex.Unlock()
+
+	a._lifetime = lifetime
+}
diff --git a/internal/client/client.go b/internal/client/client.go
new file mode 100644
index 0000000..7c22858
--- /dev/null
+++ b/internal/client/client.go
@@ -0,0 +1,18 @@
+// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
+// SPDX-License-Identifier: MIT
+
+// Package client implements the API for a TURN client
+package client
+
+import (
+	"net"
+
+	"github.com/pion/stun"
+)
+
+// Client is an interface for the public turn.Client in order to break cyclic dependencies
+type Client interface {
+	WriteTo(data []byte, to net.Addr) (int, error)
+	PerformTransaction(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error)
+	OnDeallocated(relayedAddr net.Addr)
+}
diff --git a/internal/client/errors.go b/internal/client/errors.go
index 6fde3b4..ff40d2e 100644
--- a/internal/client/errors.go
+++ b/internal/client/errors.go
@@ -11,7 +11,8 @@ var (
 	errFake                                = errors.New("fake error")
 	errTryAgain                            = errors.New("try again")
 	errClosed                              = errors.New("use of closed network connection")
-	errUDPAddrCast                         = errors.New("addr is not a net.UDPAddr")
+	errTCPAddrCast                         = errors.New("addr is not a TCP address")
+	errUDPAddrCast                         = errors.New("addr is not a UDP address")
 	errAlreadyClosed                       = errors.New("already closed")
 	errDoubleLock                          = errors.New("try-lock is already locked")
 	errTransactionClosed                   = errors.New("transaction closed")
@@ -19,6 +20,8 @@ var (
 	errFailedToBuildRefreshRequest         = errors.New("failed to build refresh request")
 	errFailedToRefreshAllocation           = errors.New("failed to refresh allocation")
 	errFailedToGetLifetime                 = errors.New("failed to get lifetime from refresh response")
+	errInvalidTURNAddress                  = errors.New("invalid TURN server address")
+	errUnexpectedSTUNRequestMessage        = errors.New("unexpected STUN request message")
 )
 
 type timeoutError struct {
diff --git a/internal/client/permission.go b/internal/client/permission.go
index 9ca3341..f5df0cc 100644
--- a/internal/client/permission.go
+++ b/internal/client/permission.go
@@ -7,6 +7,8 @@ import (
 	"net"
 	"sync"
 	"sync/atomic"
+
+	"github.com/pion/turn/v2/internal/ipnet"
 )
 
 type permState int32
@@ -17,6 +19,7 @@ const (
 )
 
 type permission struct {
+	addr  net.Addr
 	st    permState    // Thread-safe (atomic op)
 	mutex sync.RWMutex // Thread-safe
 }
@@ -38,39 +41,22 @@ type permissionMap struct {
 func (m *permissionMap) insert(addr net.Addr, p *permission) bool {
 	m.mutex.Lock()
 	defer m.mutex.Unlock()
-
-	udpAddr, ok := addr.(*net.UDPAddr)
-	if !ok {
-		return false
-	}
-
-	m.permMap[udpAddr.IP.String()] = p
+	p.addr = addr
+	m.permMap[ipnet.FingerprintAddr(addr)] = p
 	return true
 }
 
 func (m *permissionMap) find(addr net.Addr) (*permission, bool) {
 	m.mutex.RLock()
 	defer m.mutex.RUnlock()
-
-	udpAddr, ok := addr.(*net.UDPAddr)
-	if !ok {
-		return nil, false
-	}
-
-	p, ok := m.permMap[udpAddr.IP.String()]
+	p, ok := m.permMap[ipnet.FingerprintAddr(addr)]
 	return p, ok
 }
 
 func (m *permissionMap) delete(addr net.Addr) {
 	m.mutex.Lock()
 	defer m.mutex.Unlock()
-
-	udpAddr, ok := addr.(*net.UDPAddr)
-	if !ok {
-		return
-	}
-
-	delete(m.permMap, udpAddr.IP.String())
+	delete(m.permMap, ipnet.FingerprintAddr(addr))
 }
 
 func (m *permissionMap) addrs() []net.Addr {
@@ -78,10 +64,8 @@ func (m *permissionMap) addrs() []net.Addr {
 	defer m.mutex.RUnlock()
 
 	addrs := []net.Addr{}
-	for k := range m.permMap {
-		addrs = append(addrs, &net.UDPAddr{
-			IP: net.ParseIP(k),
-		})
+	for _, p := range m.permMap {
+		addrs = append(addrs, p.addr)
 	}
 	return addrs
 }
diff --git a/internal/client/tcp_alloc.go b/internal/client/tcp_alloc.go
new file mode 100644
index 0000000..3d6838b
--- /dev/null
+++ b/internal/client/tcp_alloc.go
@@ -0,0 +1,371 @@
+// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
+// SPDX-License-Identifier: MIT
+
+package client
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"math"
+	"net"
+	"time"
+
+	"github.com/pion/stun"
+	"github.com/pion/transport/v2"
+	"github.com/pion/turn/v2/internal/proto"
+)
+
+var (
+	_ transport.TCPListener = (*TCPAllocation)(nil) // Includes type check for net.Listener
+	_ transport.Dialer      = (*TCPAllocation)(nil)
+)
+
+func noDeadline() time.Time {
+	return time.Time{}
+}
+
+// TCPAllocation is an active TCP allocation on the TURN server
+// as specified by RFC 6062.
+// The allocation can be used to Dial/Accept relayed outgoing/incoming TCP connections.
+type TCPAllocation struct {
+	connAttemptCh chan *connectionAttempt
+	acceptTimer   *time.Timer
+	allocation
+}
+
+// NewTCPAllocation creates a new instance of TCPConn
+func NewTCPAllocation(config *AllocationConfig) *TCPAllocation {
+	a := &TCPAllocation{
+		connAttemptCh: make(chan *connectionAttempt, 10),
+		acceptTimer:   time.NewTimer(time.Duration(math.MaxInt64)),
+		allocation: allocation{
+			client:      config.Client,
+			relayedAddr: config.RelayedAddr,
+			serverAddr:  config.ServerAddr,
+			username:    config.Username,
+			realm:       config.Realm,
+			permMap:     newPermissionMap(),
+			integrity:   config.Integrity,
+			_nonce:      config.Nonce,
+			_lifetime:   config.Lifetime,
+			net:         config.Net,
+			log:         config.Log,
+		},
+	}
+
+	a.log.Debugf("Initial lifetime: %d seconds", int(a.lifetime().Seconds()))
+
+	a.refreshAllocTimer = NewPeriodicTimer(
+		timerIDRefreshAlloc,
+		a.onRefreshTimers,
+		a.lifetime()/2,
+	)
+
+	a.refreshPermsTimer = NewPeriodicTimer(
+		timerIDRefreshPerms,
+		a.onRefreshTimers,
+		permRefreshInterval,
+	)
+
+	if a.refreshAllocTimer.Start() {
+		a.log.Debug("Started refreshAllocTimer")
+	}
+	if a.refreshPermsTimer.Start() {
+		a.log.Debug("Started refreshPermsTimer")
+	}
+
+	return a
+}
+
+// Connect sends a Connect request to the turn server and returns a chosen connection ID
+func (a *TCPAllocation) Connect(peer net.Addr) (proto.ConnectionID, error) {
+	setters := []stun.Setter{
+		stun.TransactionID,
+		stun.NewType(stun.MethodConnect, stun.ClassRequest),
+		addr2PeerAddress(peer),
+		a.username,
+		a.realm,
+		a.nonce(),
+		a.integrity,
+		stun.Fingerprint,
+	}
+
+	msg, err := stun.Build(setters...)
+	if err != nil {
+		return 0, err
+	}
+
+	a.log.Debugf("Send connect request (peer=%v)", peer)
+	trRes, err := a.client.PerformTransaction(msg, a.serverAddr, false)
+	if err != nil {
+		return 0, err
+	}
+
+	res := trRes.Msg
+
+	if res.Type.Class == stun.ClassErrorResponse {
+		var code stun.ErrorCodeAttribute
+		if err = code.GetFrom(res); err == nil {
+			return 0, fmt.Errorf("%s (error %s)", res.Type, code) //nolint:goerr113
+		}
+
+		return 0, fmt.Errorf("%s", res.Type) //nolint:goerr113
+	}
+
+	var cid proto.ConnectionID
+	if err := cid.GetFrom(res); err != nil {
+		return 0, err
+	}
+
+	a.log.Debugf("Connect request successful (cid=%v)", cid)
+	return cid, nil
+}
+
+// Dial connects to the address on the named network.
+func (a *TCPAllocation) Dial(network, rAddrStr string) (net.Conn, error) {
+	rAddr, err := net.ResolveTCPAddr(network, rAddrStr)
+	if err != nil {
+		return nil, err
+	}
+
+	return a.DialTCP(network, nil, rAddr)
+}
+
+// DialWithConn connects to the address on the named network with an already existing connection.
+// The provided connection must be an already connected TCP connection to the TURN server.
+func (a *TCPAllocation) DialWithConn(conn net.Conn, network, rAddrStr string) (*TCPConn, error) {
+	rAddr, err := net.ResolveTCPAddr(network, rAddrStr)
+	if err != nil {
+		return nil, err
+	}
+
+	return a.DialTCPWithConn(conn, network, rAddr)
+}
+
+// DialTCP acts like Dial for TCP networks.
+func (a *TCPAllocation) DialTCP(network string, lAddr, rAddr *net.TCPAddr) (*TCPConn, error) {
+	var rAddrServer *net.TCPAddr
+	if addr, ok := a.serverAddr.(*net.TCPAddr); ok {
+		rAddrServer = &net.TCPAddr{
+			IP:   addr.IP,
+			Port: addr.Port,
+		}
+	} else {
+		return nil, errInvalidTURNAddress
+	}
+
+	conn, err := a.net.DialTCP(network, lAddr, rAddrServer)
+	if err != nil {
+		return nil, err
+	}
+
+	dataConn, err := a.DialTCPWithConn(conn, network, rAddr)
+	if err != nil {
+		conn.Close() //nolint:errcheck,gosec
+	}
+
+	return dataConn, err
+}
+
+// DialTCPWithConn acts like DialWithConn for TCP networks.
+func (a *TCPAllocation) DialTCPWithConn(conn net.Conn, _ string, rAddr *net.TCPAddr) (*TCPConn, error) {
+	var err error
+
+	// Check if we have a permission for the destination IP addr
+	perm, ok := a.permMap.find(rAddr)
+	if !ok {
+		perm = &permission{}
+		a.permMap.insert(rAddr, perm)
+	}
+
+	for i := 0; i < maxRetryAttempts; i++ {
+		if err = a.createPermission(perm, rAddr); !errors.Is(err, errTryAgain) {
+			break
+		}
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	// Send connect request if haven't done so.
+	cid, err := a.Connect(rAddr)
+	if err != nil {
+		return nil, err
+	}
+
+	tcpConn, ok := conn.(transport.TCPConn)
+	if !ok {
+		return nil, errTCPAddrCast
+	}
+
+	dataConn := &TCPConn{
+		TCPConn:       tcpConn,
+		ConnectionID:  cid,
+		remoteAddress: rAddr,
+		allocation:    a,
+	}
+
+	if err := a.BindConnection(dataConn, cid); err != nil {
+		return nil, fmt.Errorf("failed to bind connection: %w", err)
+	}
+
+	return dataConn, nil
+}
+
+// BindConnection associates the provided connection
+func (a *TCPAllocation) BindConnection(dataConn *TCPConn, cid proto.ConnectionID) error {
+	msg, err := stun.Build(
+		stun.TransactionID,
+		stun.NewType(stun.MethodConnectionBind, stun.ClassRequest),
+		cid,
+		a.username,
+		a.realm,
+		a.nonce(),
+		a.integrity,
+		stun.Fingerprint,
+	)
+	if err != nil {
+		return err
+	}
+
+	a.log.Debugf("Send connectionBind request (cid=%v)", cid)
+
+	_, err = dataConn.Write(msg.Raw)
+	if err != nil {
+		return err
+	}
+
+	// Read exactly one STUN message, any data after belongs to the user
+	b := make([]byte, stunHeaderSize)
+	n, err := dataConn.Read(b)
+	if n != stunHeaderSize {
+		return errIncompleteTURNFrame
+	} else if err != nil {
+		return err
+	}
+
+	if !stun.IsMessage(b) {
+		return errInvalidTURNFrame
+	}
+
+	datagramSize := binary.BigEndian.Uint16(b[2:4]) + stunHeaderSize
+	raw := make([]byte, datagramSize)
+	copy(raw, b)
+	_, err = dataConn.Read(raw[stunHeaderSize:])
+	if err != nil {
+		return err
+	}
+	res := &stun.Message{Raw: raw}
+	if err = res.Decode(); err != nil {
+		return fmt.Errorf("failed to decode STUN message: %w", err)
+	}
+
+	switch res.Type.Class {
+	case stun.ClassErrorResponse:
+		var code stun.ErrorCodeAttribute
+		if err = code.GetFrom(res); err == nil {
+			return fmt.Errorf("%s (error %s)", res.Type, code) //nolint:goerr113
+		}
+		return fmt.Errorf("%s", res.Type) //nolint:goerr113
+	case stun.ClassSuccessResponse:
+		a.log.Debug("Successful connectionBind request")
+		return nil
+	default:
+		return fmt.Errorf("%w: %s", errUnexpectedSTUNRequestMessage, res.String())
+	}
+}
+
+// Accept waits for and returns the next connection to the listener.
+func (a *TCPAllocation) Accept() (net.Conn, error) {
+	return a.AcceptTCP()
+}
+
+// AcceptTCP accepts the next incoming call and returns the new connection.
+func (a *TCPAllocation) AcceptTCP() (transport.TCPConn, error) {
+	addr, err := net.ResolveTCPAddr("tcp4", a.serverAddr.String())
+	if err != nil {
+		return nil, err
+	}
+
+	tcpConn, err := a.net.DialTCP("tcp", nil, addr)
+	if err != nil {
+		return nil, err
+	}
+
+	dataConn, err := a.AcceptTCPWithConn(tcpConn)
+	if err != nil {
+		tcpConn.Close() //nolint:errcheck,gosec
+	}
+
+	return dataConn, err
+}
+
+// AcceptTCPWithConn accepts the next incoming call and returns the new connection.
+func (a *TCPAllocation) AcceptTCPWithConn(conn net.Conn) (*TCPConn, error) {
+	select {
+	case attempt := <-a.connAttemptCh:
+
+		tcpConn, ok := conn.(transport.TCPConn)
+		if !ok {
+			return nil, errTCPAddrCast
+		}
+
+		dataConn := &TCPConn{
+			TCPConn:       tcpConn,
+			ConnectionID:  attempt.cid,
+			remoteAddress: attempt.from,
+			allocation:    a,
+		}
+
+		if err := a.BindConnection(dataConn, attempt.cid); err != nil {
+			return nil, fmt.Errorf("failed to bind connection: %w", err)
+		}
+
+		return dataConn, nil
+	case <-a.acceptTimer.C:
+		return nil, &net.OpError{
+			Op:   "accept",
+			Net:  a.Addr().Network(),
+			Addr: a.Addr(),
+			Err:  newTimeoutError("i/o timeout"),
+		}
+	}
+}
+
+// SetDeadline sets the deadline associated with the listener. A zero time value disables the deadline.
+func (a *TCPAllocation) SetDeadline(t time.Time) error {
+	var d time.Duration
+	if t == noDeadline() {
+		d = time.Duration(math.MaxInt64)
+	} else {
+		d = time.Until(t)
+	}
+	a.acceptTimer.Reset(d)
+	return nil
+}
+
+// Close releases the allocation
+// Any blocked Accept operations will be unblocked and return errors.
+// Any opened connection via Dial/Accept will be closed.
+func (a *TCPAllocation) Close() error {
+	a.refreshAllocTimer.Stop()
+	a.refreshPermsTimer.Stop()
+
+	a.client.OnDeallocated(a.relayedAddr)
+	return a.refreshAllocation(0, true /* dontWait=true */)
+}
+
+// Addr returns the relayed address of the allocation
+func (a *TCPAllocation) Addr() net.Addr {
+	return a.relayedAddr
+}
+
+// HandleConnectionAttempt is called by the TURN client
+// when it receives a ConnectionAttempt indication.
+func (a *TCPAllocation) HandleConnectionAttempt(from *net.TCPAddr, cid proto.ConnectionID) {
+	a.connAttemptCh <- &connectionAttempt{
+		from: from,
+		cid:  cid,
+	}
+}
diff --git a/internal/client/tcp_conn.go b/internal/client/tcp_conn.go
new file mode 100644
index 0000000..06d50eb
--- /dev/null
+++ b/internal/client/tcp_conn.go
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
+// SPDX-License-Identifier: MIT
+
+package client
+
+import (
+	"errors"
+	"net"
+
+	"github.com/pion/transport/v2"
+	"github.com/pion/turn/v2/internal/proto"
+)
+
+var (
+	errInvalidTURNFrame    = errors.New("data is not a valid TURN frame, no STUN or ChannelData found")
+	errIncompleteTURNFrame = errors.New("data contains incomplete STUN or TURN frame")
+)
+
+const (
+	stunHeaderSize = 20
+)
+
+var _ transport.TCPConn = (*TCPConn)(nil) // Includes type check for net.Conn
+
+// TCPConn wraps a transport.TCPConn and returns the allocations relayed
+// transport address in response to TCPConn.LocalAddress()
+type TCPConn struct {
+	transport.TCPConn
+	remoteAddress *net.TCPAddr
+	allocation    *TCPAllocation
+	ConnectionID  proto.ConnectionID
+}
+
+type connectionAttempt struct {
+	from *net.TCPAddr
+	cid  proto.ConnectionID
+}
+
+// LocalAddr returns the local network address.
+// The Addr returned is shared by all invocations of LocalAddr, so do not modify it.
+func (c *TCPConn) LocalAddr() net.Addr {
+	return c.allocation.Addr()
+}
+
+// RemoteAddr returns the remote network address.
+// The Addr returned is shared by all invocations of RemoteAddr, so do not modify it.
+func (c *TCPConn) RemoteAddr() net.Addr {
+	return c.remoteAddress
+}
diff --git a/internal/client/conn.go b/internal/client/udp_conn.go
similarity index 62%
rename from internal/client/conn.go
rename to internal/client/udp_conn.go
index e1a738f..aea819e 100644
--- a/internal/client/conn.go
+++ b/internal/client/udp_conn.go
@@ -10,10 +10,8 @@ import (
 	"io"
 	"math"
 	"net"
-	"sync"
 	"time"
 
-	"github.com/pion/logging"
 	"github.com/pion/stun"
 	"github.com/pion/turn/v2/internal/proto"
 )
@@ -29,68 +27,40 @@ const (
 	timerIDRefreshPerms
 )
 
-func noDeadline() time.Time {
-	return time.Time{}
-}
-
 type inboundData struct {
 	data []byte
 	from net.Addr
 }
 
-// UDPConnObserver is an interface to UDPConn observer
-type UDPConnObserver interface {
-	TURNServerAddr() net.Addr
-	Username() stun.Username
-	Realm() stun.Realm
-	WriteTo(data []byte, to net.Addr) (int, error)
-	PerformTransaction(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error)
-	OnDeallocated(relayedAddr net.Addr)
-}
-
-// UDPConnConfig is a set of configuration params use by NewUDPConn
-type UDPConnConfig struct {
-	Observer    UDPConnObserver
-	RelayedAddr net.Addr
-	Integrity   stun.MessageIntegrity
-	Nonce       stun.Nonce
-	Lifetime    time.Duration
-	Log         logging.LeveledLogger
-}
-
 // UDPConn is the implementation of the Conn and PacketConn interfaces for UDP network connections.
 // compatible with net.PacketConn and net.Conn
 type UDPConn struct {
-	obs               UDPConnObserver       // Read-only
-	relayedAddr       net.Addr              // Read-only
-	permMap           *permissionMap        // Thread-safe
-	bindingMgr        *bindingManager       // Thread-safe
-	integrity         stun.MessageIntegrity // Read-only
-	_nonce            stun.Nonce            // Needs mutex x
-	_lifetime         time.Duration         // Needs mutex x
-	readCh            chan *inboundData     // Thread-safe
-	closeCh           chan struct{}         // Thread-safe
-	readTimer         *time.Timer           // Thread-safe
-	refreshAllocTimer *PeriodicTimer        // Thread-safe
-	refreshPermsTimer *PeriodicTimer        // Thread-safe
-	mutex             sync.RWMutex          // Thread-safe
-	log               logging.LeveledLogger // Read-only
+	bindingMgr *bindingManager   // Thread-safe
+	readCh     chan *inboundData // Thread-safe
+	closeCh    chan struct{}     // Thread-safe
+	allocation
 }
 
 // NewUDPConn creates a new instance of UDPConn
-func NewUDPConn(config *UDPConnConfig) *UDPConn {
+func NewUDPConn(config *AllocationConfig) *UDPConn {
 	c := &UDPConn{
-		obs:         config.Observer,
-		relayedAddr: config.RelayedAddr,
-		permMap:     newPermissionMap(),
-		bindingMgr:  newBindingManager(),
-		integrity:   config.Integrity,
-		_nonce:      config.Nonce,
-		_lifetime:   config.Lifetime,
-		readCh:      make(chan *inboundData, maxReadQueueSize),
-		closeCh:     make(chan struct{}),
-		readTimer:   time.NewTimer(time.Duration(math.MaxInt64)),
-		log:         config.Log,
+		bindingMgr: newBindingManager(),
+		readCh:     make(chan *inboundData, maxReadQueueSize),
+		closeCh:    make(chan struct{}),
+		allocation: allocation{
+			client:      config.Client,
+			relayedAddr: config.RelayedAddr,
+			serverAddr:  config.ServerAddr,
+			readTimer:   time.NewTimer(time.Duration(math.MaxInt64)),
+			permMap:     newPermissionMap(),
+			username:    config.Username,
+			realm:       config.Realm,
+			integrity:   config.Integrity,
+			_nonce:      config.Nonce,
+			_lifetime:   config.Lifetime,
+			net:         config.Net,
+			log:         config.Log,
+		},
 	}
 
 	c.log.Debugf("initial lifetime: %d seconds", int(c.lifetime().Seconds()))
@@ -156,14 +126,14 @@ func (c *UDPConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
 	}
 }
 
-func (c *UDPConn) createPermission(perm *permission, addr net.Addr) error {
+func (a *allocation) createPermission(perm *permission, addr net.Addr) error {
 	perm.mutex.Lock()
 	defer perm.mutex.Unlock()
 
 	if perm.state() == permStateIdle {
 		// Punch a hole! (this would block a bit..)
-		if err := c.CreatePermissions(addr); err != nil {
-			c.permMap.delete(addr)
+		if err := a.CreatePermissions(addr); err != nil {
+			a.permMap.delete(addr)
 			return err
 		}
 		perm.setState(permStatePermitted)
@@ -237,7 +207,7 @@ func (c *UDPConn) WriteTo(p []byte, addr net.Addr) (int, error) { //nolint: goco
 			}
 		}()
 
-		// send data using SendIndication
+		// Send data using SendIndication
 		peerAddr := addr2PeerAddress(addr)
 		var msg *stun.Message
 		msg, err = stun.Build(
@@ -253,7 +223,7 @@ func (c *UDPConn) WriteTo(p []byte, addr net.Addr) (int, error) { //nolint: goco
 
 		// Indication has no transaction (fire-and-forget)
 
-		return c.obs.WriteTo(msg.Raw, c.obs.TURNServerAddr())
+		return c.client.WriteTo(msg.Raw, c.serverAddr)
 	}
 
 	// Binding is either ready
@@ -300,7 +270,7 @@ func (c *UDPConn) Close() error {
 		close(c.closeCh)
 	}
 
-	c.obs.OnDeallocated(c.relayedAddr)
+	c.client.OnDeallocated(c.relayedAddr)
 	return c.refreshAllocation(0, true /* dontWait=true */)
 }
 
@@ -368,7 +338,7 @@ func addr2PeerAddress(addr net.Addr) proto.PeerAddress {
 
 // CreatePermissions Issues a CreatePermission request for the supplied addresses
 // as described in https://datatracker.ietf.org/doc/html/rfc5766#section-9
-func (c *UDPConn) CreatePermissions(addrs ...net.Addr) error {
+func (a *allocation) CreatePermissions(addrs ...net.Addr) error {
 	setters := []stun.Setter{
 		stun.TransactionID,
 		stun.NewType(stun.MethodCreatePermission, stun.ClassRequest),
@@ -379,10 +349,10 @@ func (c *UDPConn) CreatePermissions(addrs ...net.Addr) error {
 	}
 
 	setters = append(setters,
-		c.obs.Username(),
-		c.obs.Realm(),
-		c.nonce(),
-		c.integrity,
+		a.username,
+		a.realm,
+		a.nonce(),
+		a.integrity,
 		stun.Fingerprint)
 
 	msg, err := stun.Build(setters...)
@@ -390,7 +360,7 @@ func (c *UDPConn) CreatePermissions(addrs ...net.Addr) error {
 		return err
 	}
 
-	trRes, err := c.obs.PerformTransaction(msg, c.obs.TURNServerAddr(), false)
+	trRes, err := a.client.PerformTransaction(msg, a.serverAddr, false)
 	if err != nil {
 		return err
 	}
@@ -401,12 +371,11 @@ func (c *UDPConn) CreatePermissions(addrs ...net.Addr) error {
 		var code stun.ErrorCodeAttribute
 		if err = code.GetFrom(res); err == nil {
 			if code.Code == stun.CodeStaleNonce {
-				c.setNonceFromMsg(res)
+				a.setNonceFromMsg(res)
 				return errTryAgain
 			}
 			return fmt.Errorf("%s (error %s)", res.Type, code) //nolint:goerr113
 		}
-
 		return fmt.Errorf("%s", res.Type) //nolint:goerr113
 	}
 
@@ -415,7 +384,7 @@ func (c *UDPConn) CreatePermissions(addrs ...net.Addr) error {
 
 // HandleInbound passes inbound data in UDPConn
 func (c *UDPConn) HandleInbound(data []byte, from net.Addr) {
-	// copy data
+	// Copy data
 	copied := make([]byte, len(data))
 	copy(copied, data)
 
@@ -436,94 +405,14 @@ func (c *UDPConn) FindAddrByChannelNumber(chNum uint16) (net.Addr, bool) {
 	return b.addr, true
 }
 
-func (c *UDPConn) setNonceFromMsg(msg *stun.Message) {
-	// Update nonce
-	var nonce stun.Nonce
-	if err := nonce.GetFrom(msg); err == nil {
-		c.setNonce(nonce)
-		c.log.Debug("refresh allocation: 438, got new nonce.")
-	} else {
-		c.log.Warn("refresh allocation: 438 but no nonce.")
-	}
-}
-
-func (c *UDPConn) refreshAllocation(lifetime time.Duration, dontWait bool) error {
-	msg, err := stun.Build(
-		stun.TransactionID,
-		stun.NewType(stun.MethodRefresh, stun.ClassRequest),
-		proto.Lifetime{Duration: lifetime},
-		c.obs.Username(),
-		c.obs.Realm(),
-		c.nonce(),
-		c.integrity,
-		stun.Fingerprint,
-	)
-	if err != nil {
-		return fmt.Errorf("%w: %s", errFailedToBuildRefreshRequest, err.Error())
-	}
-
-	c.log.Debugf("send refresh request (dontWait=%v)", dontWait)
-	trRes, err := c.obs.PerformTransaction(msg, c.obs.TURNServerAddr(), dontWait)
-	if err != nil {
-		return fmt.Errorf("%w: %s", errFailedToRefreshAllocation, err.Error())
-	}
-
-	if dontWait {
-		c.log.Debug("refresh request sent")
-		return nil
-	}
-
-	c.log.Debug("refresh request sent, and waiting response")
-
-	res := trRes.Msg
-	if res.Type.Class == stun.ClassErrorResponse {
-		var code stun.ErrorCodeAttribute
-		if err = code.GetFrom(res); err == nil {
-			if code.Code == stun.CodeStaleNonce {
-				c.setNonceFromMsg(res)
-				return errTryAgain
-			}
-			return err
-		}
-		return fmt.Errorf("%s", res.Type) //nolint:goerr113
-	}
-
-	// Getting lifetime from response
-	var updatedLifetime proto.Lifetime
-	if err := updatedLifetime.GetFrom(res); err != nil {
-		return fmt.Errorf("%w: %s", errFailedToGetLifetime, err.Error())
-	}
-
-	c.setLifetime(updatedLifetime.Duration)
-	c.log.Debugf("updated lifetime: %d seconds", int(c.lifetime().Seconds()))
-	return nil
-}
-
-func (c *UDPConn) refreshPermissions() error {
-	addrs := c.permMap.addrs()
-	if len(addrs) == 0 {
-		c.log.Debug("no permission to refresh")
-		return nil
-	}
-	if err := c.CreatePermissions(addrs...); err != nil {
-		if errors.Is(err, errTryAgain) {
-			return errTryAgain
-		}
-		c.log.Errorf("fail to refresh permissions: %s", err.Error())
-		return err
-	}
-	c.log.Debug("refresh permissions successful")
-	return nil
-}
-
 func (c *UDPConn) bind(b *binding) error {
 	setters := []stun.Setter{
 		stun.TransactionID,
 		stun.NewType(stun.MethodChannelBind, stun.ClassRequest),
 		addr2PeerAddress(b.addr),
 		proto.ChannelNumber(b.number),
-		c.obs.Username(),
-		c.obs.Realm(),
+		c.username,
+		c.realm,
 		c.nonce(),
 		c.integrity,
 		stun.Fingerprint,
@@ -534,7 +423,7 @@ func (c *UDPConn) bind(b *binding) error {
 		return err
 	}
 
-	trRes, err := c.obs.PerformTransaction(msg, c.obs.TURNServerAddr(), false)
+	trRes, err := c.client.PerformTransaction(msg, c.serverAddr, false)
 	if err != nil {
 		c.bindingMgr.deleteByAddr(b.addr)
 		return err
@@ -558,69 +447,9 @@ func (c *UDPConn) sendChannelData(data []byte, chNum uint16) (int, error) {
 		Number: proto.ChannelNumber(chNum),
 	}
 	chData.Encode()
-	_, err := c.obs.WriteTo(chData.Raw, c.obs.TURNServerAddr())
+	_, err := c.client.WriteTo(chData.Raw, c.serverAddr)
 	if err != nil {
 		return 0, err
 	}
 	return len(data), nil
 }
-
-func (c *UDPConn) onRefreshTimers(id int) {
-	c.log.Debugf("refresh timer %d expired", id)
-	switch id {
-	case timerIDRefreshAlloc:
-		var err error
-		lifetime := c.lifetime()
-		// Limit the max retries on errTryAgain to 3
-		// when stale nonce returns, sencond retry should succeed
-		for i := 0; i < maxRetryAttempts; i++ {
-			err = c.refreshAllocation(lifetime, false)
-			if !errors.Is(err, errTryAgain) {
-				break
-			}
-		}
-		if err != nil {
-			c.log.Warnf("refresh allocation failed")
-		}
-	case timerIDRefreshPerms:
-		var err error
-		for i := 0; i < maxRetryAttempts; i++ {
-			err = c.refreshPermissions()
-			if !errors.Is(err, errTryAgain) {
-				break
-			}
-		}
-		if err != nil {
-			c.log.Warnf("refresh permissions failed")
-		}
-	}
-}
-
-func (c *UDPConn) nonce() stun.Nonce {
-	c.mutex.RLock()
-	defer c.mutex.RUnlock()
-
-	return c._nonce
-}
-
-func (c *UDPConn) setNonce(nonce stun.Nonce) {
-	c.mutex.Lock()
-	defer c.mutex.Unlock()
-
-	c.log.Debugf("set new nonce with %d bytes", len(nonce))
-	c._nonce = nonce
-}
-
-func (c *UDPConn) lifetime() time.Duration {
-	c.mutex.RLock()
-	defer c.mutex.RUnlock()
-
-	return c._lifetime
-}
-
-func (c *UDPConn) setLifetime(lifetime time.Duration) {
-	c.mutex.Lock()
-	defer c.mutex.Unlock()
-
-	c._lifetime = lifetime
-}
diff --git a/internal/ipnet/util.go b/internal/ipnet/util.go
index 4f4f5ab..c3a5b32 100644
--- a/internal/ipnet/util.go
+++ b/internal/ipnet/util.go
@@ -41,3 +41,15 @@ func AddrEqual(a, b net.Addr) bool {
 
 	return aUDP.IP.Equal(bUDP.IP) && aUDP.Port == bUDP.Port
 }
+
+// FingerprintAddr generates a fingerprint from net.UDPAddr or net.TCPAddr's
+// which can be used for indexing maps.
+func FingerprintAddr(addr net.Addr) string {
+	switch a := addr.(type) {
+	case *net.UDPAddr:
+		return a.IP.String()
+	case *net.TCPAddr: // Do we really need this case?
+		return a.IP.String()
+	}
+	return "" // Should never happen
+}
diff --git a/internal/proto/reqtrans.go b/internal/proto/reqtrans.go
index 9af38ad..5dd446d 100644
--- a/internal/proto/reqtrans.go
+++ b/internal/proto/reqtrans.go
@@ -13,12 +13,16 @@ import (
 type Protocol byte
 
 const (
+	// ProtoTCP is IANA assigned protocol number for TCP.
+	ProtoTCP Protocol = 6
 	// ProtoUDP is IANA assigned protocol number for UDP.
 	ProtoUDP Protocol = 17
 )
 
 func (p Protocol) String() string {
 	switch p {
+	case ProtoTCP:
+		return "TCP"
 	case ProtoUDP:
 		return "UDP"
 	default:
diff --git a/internal/server/errors.go b/internal/server/errors.go
index 7461af6..3a8911c 100644
--- a/internal/server/errors.go
+++ b/internal/server/errors.go
@@ -18,7 +18,7 @@ var (
 	errFailedToCreateSTUNPacket               = errors.New("failed to create stun message from packet")
 	errFailedToCreateChannelData              = errors.New("failed to create channel data from packet")
 	errRelayAlreadyAllocatedForFiveTuple      = errors.New("relay already allocated for 5-TUPLE")
-	errRequestedTransportMustBeUDP            = errors.New("RequestedTransport must be UDP")
+	errUnsupportedTransportProtocol           = errors.New("RequestedTransport must be UDP or TCP")
 	errNoDontFragmentSupport                  = errors.New("no support for DONT-FRAGMENT")
 	errRequestWithReservationTokenAndEvenPort = errors.New("Request must not contain RESERVATION-TOKEN and EVEN-PORT")
 	errNoAllocationFound                      = errors.New("no allocation found")
diff --git a/internal/server/turn.go b/internal/server/turn.go
index 166bef0..11a73d8 100644
--- a/internal/server/turn.go
+++ b/internal/server/turn.go
@@ -56,14 +56,14 @@ func handleAllocateRequest(r Request, m *stun.Message) error {
 	//    attribute.  If the REQUESTED-TRANSPORT attribute is not included
 	//    or is malformed, the server rejects the request with a 400 (Bad
 	//    Request) error.  Otherwise, if the attribute is included but
-	//    specifies a protocol other that UDP, the server rejects the
+	//    specifies a protocol other that UDP/TCP, the server rejects the
 	//    request with a 442 (Unsupported Transport Protocol) error.
 	var requestedTransport proto.RequestedTransport
 	if err = requestedTransport.GetFrom(m); err != nil {
 		return buildAndSendErr(r.Conn, r.SrcAddr, err, badRequestMsg...)
-	} else if requestedTransport.Protocol != proto.ProtoUDP {
+	} else if requestedTransport.Protocol != proto.ProtoUDP && requestedTransport.Protocol != proto.ProtoTCP {
 		msg := buildMsg(m.TransactionID, stun.NewType(stun.MethodAllocate, stun.ClassErrorResponse), &stun.ErrorCodeAttribute{Code: stun.CodeUnsupportedTransProto})
-		return buildAndSendErr(r.Conn, r.SrcAddr, errRequestedTransportMustBeUDP, msg...)
+		return buildAndSendErr(r.Conn, r.SrcAddr, errUnsupportedTransportProtocol, msg...)
 	}
 
 	// 4. The request may contain a DONT-FRAGMENT attribute.  If it does,
@@ -131,7 +131,8 @@ func handleAllocateRequest(r Request, m *stun.Message) error {
 	}
 
 	// Once the allocation is created, the server replies with a success
-	// response.  The success response contains:
+	// response.
+	// The success response contains:
 	//   * An XOR-RELAYED-ADDRESS attribute containing the relayed transport
 	//     address.
 	//   * A LIFETIME attribute containing the current value of the time-to-


Test patch:
diff --git a/client_test.go b/client_test.go
index 2477233..1867898 100644
--- a/client_test.go
+++ b/client_test.go
@@ -12,9 +12,16 @@ import (
 	"time"
 
 	"github.com/pion/logging"
+	"github.com/pion/stun"
+	"github.com/pion/turn/v2/internal/proto"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
+func buildMsg(transactionID [stun.TransactionIDSize]byte, msgType stun.MessageType, additional ...stun.Setter) []stun.Setter {
+	return append([]stun.Setter{&stun.Message{TransactionID: transactionID}, msgType}, additional...)
+}
+
 func createListeningTestClient(t *testing.T, loggerFactory logging.LoggerFactory) (*Client, net.PacketConn, bool) {
 	conn, err := net.ListenPacket("udp4", "0.0.0.0:0")
 	assert.NoError(t, err)
@@ -188,3 +195,74 @@ func TestClientNonceExpiration(t *testing.T) {
 	assert.NoError(t, conn.Close())
 	assert.NoError(t, server.Close())
 }
+
+// Create a TCP-based allocation and verify allocation can be created
+func TestTCPClient(t *testing.T) {
+	// Setup server
+	tcpListener, err := net.Listen("tcp4", "0.0.0.0:13478") //nolint: gosec
+	require.NoError(t, err)
+
+	server, err := NewServer(ServerConfig{
+		AuthHandler: func(username, realm string, srcAddr net.Addr) (key []byte, ok bool) {
+			return GenerateAuthKey(username, realm, "pass"), true
+		},
+		ListenerConfigs: []ListenerConfig{
+			{
+				Listener: tcpListener,
+				RelayAddressGenerator: &RelayAddressGeneratorStatic{
+					RelayAddress: net.ParseIP("127.0.0.1"),
+					Address:      "0.0.0.0",
+				},
+			},
+		},
+		Realm: "pion.ly",
+	})
+	require.NoError(t, err)
+
+	// Setup clients
+	conn, err := net.Dial("tcp", "127.0.0.1:13478")
+	require.NoError(t, err)
+
+	serverAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:13478")
+	require.NoError(t, err)
+
+	client, err := NewClient(&ClientConfig{
+		Conn:           NewSTUNConn(conn),
+		STUNServerAddr: serverAddr,
+		TURNServerAddr: serverAddr,
+		Username:       "foo",
+		Password:       "pass",
+	})
+	require.NoError(t, err)
+	require.NoError(t, client.Listen())
+
+	require.Equal(t, serverAddr, client.STUNServerAddr())
+
+	allocation, err := client.AllocateTCP()
+	require.NoError(t, err)
+
+	peerAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:12345")
+	require.NoError(t, err)
+	err = client.CreatePermission(peerAddr)
+	require.NoError(t, err)
+
+	var cid proto.ConnectionID = 5
+	transactionID := [stun.TransactionIDSize]byte{1, 2, 3}
+	attrs := buildMsg(
+		transactionID,
+		stun.NewType(stun.MethodConnectionAttempt, stun.ClassIndication),
+		cid,
+		proto.PeerAddress{IP: peerAddr.IP, Port: peerAddr.Port},
+	)
+
+	msg, err := stun.Build(attrs...)
+	require.NoError(t, err)
+
+	err = client.handleSTUNMessage(msg.Raw, peerAddr)
+	require.NoError(t, err)
+
+	// Shutdown
+	require.NoError(t, allocation.Close())
+	require.NoError(t, conn.Close())
+	require.NoError(t, server.Close())
+}
diff --git a/internal/client/client_test.go b/internal/client/client_test.go
new file mode 100644
index 0000000..a73f87e
--- /dev/null
+++ b/internal/client/client_test.go
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
+// SPDX-License-Identifier: MIT
+
+package client
+
+import (
+	"net"
+
+	"github.com/pion/stun"
+)
+
+type mockClient struct {
+	writeTo            func(data []byte, to net.Addr) (int, error)
+	performTransaction func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error)
+	onDeallocated      func(relayedAddr net.Addr)
+}
+
+func (c *mockClient) WriteTo(data []byte, to net.Addr) (int, error) {
+	if c.writeTo != nil {
+		return c.writeTo(data, to)
+	}
+	return 0, nil
+}
+
+func (c *mockClient) PerformTransaction(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
+	if c.performTransaction != nil {
+		return c.performTransaction(msg, to, dontWait)
+	}
+	return TransactionResult{}, nil
+}
+
+func (c *mockClient) OnDeallocated(relayedAddr net.Addr) {
+	if c.onDeallocated != nil {
+		c.onDeallocated(relayedAddr)
+	}
+}
diff --git a/internal/client/conn_test.go b/internal/client/conn_test.go
deleted file mode 100644
index 13d5759..0000000
--- a/internal/client/conn_test.go
+++ /dev/null
@@ -1,115 +0,0 @@
-// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
-// SPDX-License-Identifier: MIT
-
-package client
-
-import (
-	"net"
-	"testing"
-
-	"github.com/pion/stun"
-	"github.com/stretchr/testify/assert"
-)
-
-type dummyUDPConnObserver struct {
-	turnServerAddr      net.Addr
-	username            stun.Username
-	realm               stun.Realm
-	_writeTo            func(data []byte, to net.Addr) (int, error)
-	_performTransaction func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error)
-	_onDeallocated      func(relayedAddr net.Addr)
-}
-
-func (obs *dummyUDPConnObserver) TURNServerAddr() net.Addr {
-	return obs.turnServerAddr
-}
-
-func (obs *dummyUDPConnObserver) Username() stun.Username {
-	return obs.username
-}
-
-func (obs *dummyUDPConnObserver) Realm() stun.Realm {
-	return obs.realm
-}
-
-func (obs *dummyUDPConnObserver) WriteTo(data []byte, to net.Addr) (int, error) {
-	if obs._writeTo != nil {
-		return obs._writeTo(data, to)
-	}
-	return 0, nil
-}
-
-func (obs *dummyUDPConnObserver) PerformTransaction(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
-	if obs._performTransaction != nil {
-		return obs._performTransaction(msg, to, dontWait)
-	}
-	return TransactionResult{}, nil
-}
-
-func (obs *dummyUDPConnObserver) OnDeallocated(relayedAddr net.Addr) {
-	if obs._onDeallocated != nil {
-		obs._onDeallocated(relayedAddr)
-	}
-}
-
-func TestUDPConn(t *testing.T) {
-	t.Run("bind()", func(t *testing.T) {
-		obs := &dummyUDPConnObserver{
-			_performTransaction: func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
-				return TransactionResult{}, errFake
-			},
-		}
-
-		bm := newBindingManager()
-		b := bm.create(&net.UDPAddr{
-			IP:   net.ParseIP("127.0.0.1"),
-			Port: 1234,
-		})
-
-		conn := UDPConn{
-			obs:        obs,
-			bindingMgr: bm,
-		}
-
-		err := conn.bind(b)
-		assert.Error(t, err, "should fail")
-		assert.Equal(t, 0, len(bm.chanMap), "should be 0")
-		assert.Equal(t, 0, len(bm.addrMap), "should be 0")
-	})
-
-	t.Run("WriteTo()", func(t *testing.T) {
-		obs := &dummyUDPConnObserver{
-			_performTransaction: func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
-				return TransactionResult{}, errFake
-			},
-			_writeTo: func(data []byte, to net.Addr) (int, error) {
-				return len(data), nil
-			},
-		}
-
-		addr := &net.UDPAddr{
-			IP:   net.ParseIP("127.0.0.1"),
-			Port: 1234,
-		}
-
-		pm := newPermissionMap()
-		assert.True(t, pm.insert(addr, &permission{
-			st: permStatePermitted,
-		}))
-
-		bm := newBindingManager()
-		binding := bm.create(addr)
-		binding.setState(bindingStateReady)
-
-		conn := UDPConn{
-			obs:        obs,
-			permMap:    pm,
-			bindingMgr: bm,
-		}
-
-		buf := []byte("Hello")
-		n, err := conn.WriteTo(buf, addr)
-		assert.NoError(t, err, "should fail")
-		assert.Equal(t, len(buf), n)
-	})
-}
diff --git a/internal/client/permission_test.go b/internal/client/permission_test.go
index 35bcf40..7b65d37 100644
--- a/internal/client/permission_test.go
+++ b/internal/client/permission_test.go
@@ -5,6 +5,8 @@ package client
 
 import (
 	"net"
+	"sort"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -28,15 +30,16 @@ func TestPermissionMap(t *testing.T) {
 
 		perm1 := &permission{st: permStateIdle}
 		perm2 := &permission{st: permStatePermitted}
+		perm3 := &permission{st: permStateIdle}
 		udpAddr1, _ := net.ResolveUDPAddr("udp", "1.2.3.4:5000")
 		udpAddr2, _ := net.ResolveUDPAddr("udp", "5.6.7.8:8888")
-		tcpAddr, _ := net.ResolveTCPAddr("tcp", "1.2.3.4:5000")
+		tcpAddr, _ := net.ResolveTCPAddr("tcp", "7.8.9.10:5000")
 		assert.True(t, pm.insert(udpAddr1, perm1))
 		assert.Equal(t, 1, len(pm.permMap))
 		assert.True(t, pm.insert(udpAddr2, perm2))
 		assert.Equal(t, 2, len(pm.permMap))
-		assert.False(t, pm.insert(tcpAddr, perm1))
-		assert.Equal(t, 2, len(pm.permMap))
+		assert.True(t, pm.insert(tcpAddr, perm3))
+		assert.Equal(t, 3, len(pm.permMap))
 
 		p, ok := pm.find(udpAddr1)
 		assert.True(t, ok)
@@ -48,26 +51,38 @@ func TestPermissionMap(t *testing.T) {
 		assert.Equal(t, perm2, p)
 		assert.Equal(t, permStatePermitted, p.st)
 
-		_, ok = pm.find(tcpAddr)
-		assert.False(t, ok)
+		p, ok = pm.find(tcpAddr)
+		assert.True(t, ok)
+		assert.Equal(t, perm3, p)
+		assert.Equal(t, permStateIdle, p.st)
 
 		addrs := pm.addrs()
 		ips := []net.IP{}
+
 		for _, addr := range addrs {
-			udpAddr, err := net.ResolveUDPAddr(addr.Network(), addr.String())
-			assert.NoError(t, err)
-			assert.Equal(t, 0, udpAddr.Port)
-			ips = append(ips, udpAddr.IP)
-		}
+			switch addr.(type) {
+			case *net.UDPAddr:
+				addr, err := net.ResolveUDPAddr(addr.Network(), addr.String())
+				assert.NoError(t, err)
+
+				ips = append(ips, addr.IP)
+			case *net.TCPAddr:
+				addr, err := net.ResolveTCPAddr(addr.Network(), addr.String())
+				assert.NoError(t, err)
 
-		assert.Equal(t, 2, len(ips))
-		if ips[0].Equal(udpAddr1.IP) {
-			assert.True(t, ips[1].Equal(udpAddr2.IP))
-		} else {
-			assert.True(t, ips[0].Equal(udpAddr2.IP))
-			assert.True(t, ips[1].Equal(udpAddr1.IP))
+				ips = append(ips, addr.IP)
+			}
 		}
 
+		assert.Equal(t, 3, len(ips))
+		sort.Slice(ips, func(i, j int) bool {
+			return strings.Compare(ips[i].String(), ips[j].String()) < 0
+		})
+
+		assert.True(t, ips[0].Equal(udpAddr1.IP))
+		assert.True(t, ips[1].Equal(udpAddr2.IP))
+		assert.True(t, ips[2].Equal(tcpAddr.IP))
+
 		pm.delete(tcpAddr)
 		assert.Equal(t, 2, len(pm.permMap))
 		pm.delete(udpAddr1)
diff --git a/internal/client/tcp_conn_test.go b/internal/client/tcp_conn_test.go
new file mode 100644
index 0000000..c51bb54
--- /dev/null
+++ b/internal/client/tcp_conn_test.go
@@ -0,0 +1,189 @@
+// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
+// SPDX-License-Identifier: MIT
+
+package client
+
+import (
+	"net"
+	"testing"
+	"time"
+
+	"github.com/pion/logging"
+	"github.com/pion/stun"
+	"github.com/pion/transport/v2"
+	"github.com/pion/turn/v2/internal/proto"
+	"github.com/stretchr/testify/assert"
+)
+
+type dummyTCPConn struct {
+	transport.TCPConn
+}
+
+func buildMsg(transactionID [stun.TransactionIDSize]byte, msgType stun.MessageType, additional ...stun.Setter) []stun.Setter {
+	return append([]stun.Setter{&stun.Message{TransactionID: transactionID}, msgType}, additional...)
+}
+
+func (c dummyTCPConn) Write(b []byte) (int, error) {
+	return len(b), nil
+}
+
+func (c dummyTCPConn) Read(b []byte) (int, error) {
+	transactionID := [stun.TransactionIDSize]byte{1, 2, 3}
+	messageType := stun.MessageType{Method: stun.MethodConnectionBind, Class: stun.ClassSuccessResponse}
+	attrs := buildMsg(transactionID, messageType)
+	msg, err := stun.Build(attrs...)
+	if err != nil {
+		return 0, err
+	}
+
+	copy(b, msg.Raw)
+	return len(msg.Raw), nil
+}
+
+func TestTCPConn(t *testing.T) {
+	t.Run("Connect()", func(t *testing.T) {
+		var cid proto.ConnectionID = 5
+		client := &mockClient{
+			performTransaction: func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
+				if msg.Type.Class == stun.ClassRequest && msg.Type.Method == stun.MethodConnect {
+					msg, err := stun.Build(
+						stun.TransactionID,
+						stun.NewType(stun.MethodConnect, stun.ClassSuccessResponse),
+						cid,
+					)
+					assert.NoError(t, err)
+					return TransactionResult{Msg: msg}, nil
+				}
+				return TransactionResult{}, errFake
+			},
+		}
+
+		addr := &net.TCPAddr{
+			IP:   net.ParseIP("127.0.0.1"),
+			Port: 1234,
+		}
+
+		pm := newPermissionMap()
+		assert.True(t, pm.insert(addr, &permission{
+			st: permStatePermitted,
+		}))
+
+		loggerFactory := logging.NewDefaultLoggerFactory()
+		log := loggerFactory.NewLogger("test")
+		alloc := TCPAllocation{
+			allocation: allocation{
+				client:  client,
+				permMap: pm,
+				log:     log,
+			},
+		}
+
+		actualCid, err := alloc.Connect(addr)
+		assert.NoError(t, err)
+		assert.Equal(t, cid, actualCid)
+
+		client = &mockClient{
+			performTransaction: func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
+				if msg.Type.Class == stun.ClassRequest && msg.Type.Method == stun.MethodConnect {
+					msg, err = stun.Build(
+						stun.TransactionID,
+						stun.NewType(stun.MethodConnect, stun.ClassErrorResponse),
+						stun.ErrorCodeAttribute{Code: stun.CodeBadRequest},
+					)
+					assert.NoError(t, err)
+					return TransactionResult{Msg: msg}, nil
+				}
+				return TransactionResult{}, errFake
+			},
+		}
+		alloc = TCPAllocation{
+			allocation: allocation{
+				client:  client,
+				permMap: pm,
+				log:     log,
+			},
+		}
+
+		_, err = alloc.Connect(addr)
+		assert.ErrorContains(t, err, "Connect error response", "error 400")
+	})
+
+	t.Run("SetDeadline()", func(t *testing.T) {
+		relayedAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:13478")
+		assert.NoError(t, err)
+
+		loggerFactory := logging.NewDefaultLoggerFactory()
+
+		alloc := NewTCPAllocation(&AllocationConfig{
+			Client:      &mockClient{},
+			Lifetime:    time.Second,
+			Log:         loggerFactory.NewLogger("test"),
+			RelayedAddr: relayedAddr,
+		})
+
+		err = alloc.SetDeadline(time.Now())
+		assert.NoError(t, err)
+
+		cid, err := alloc.AcceptTCPWithConn(nil)
+		assert.Nil(t, cid)
+		assert.Contains(t, err.Error(), "i/o timeout")
+	})
+
+	t.Run("AcceptTCPWithConn()", func(t *testing.T) {
+		relayedAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:13478")
+		assert.NoError(t, err)
+
+		loggerFactory := logging.NewDefaultLoggerFactory()
+		alloc := NewTCPAllocation(&AllocationConfig{
+			Client:      &mockClient{},
+			Lifetime:    time.Second,
+			Log:         loggerFactory.NewLogger("test"),
+			RelayedAddr: relayedAddr,
+		})
+
+		from, err := net.ResolveTCPAddr("tcp", "127.0.0.1:11111")
+		var cid proto.ConnectionID = 5
+		assert.NoError(t, err)
+		alloc.HandleConnectionAttempt(from, cid)
+
+		conn := dummyTCPConn{}
+		dataConn, err := alloc.AcceptTCPWithConn(conn)
+		assert.Equal(t, cid, dataConn.ConnectionID)
+		assert.NoError(t, err)
+	})
+
+	t.Run("DialWithConn()", func(t *testing.T) {
+		relayedAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:13478")
+		assert.NoError(t, err)
+
+		var cid proto.ConnectionID = 5
+		loggerFactory := logging.NewDefaultLoggerFactory()
+		client := &mockClient{
+			performTransaction: func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
+				typ := stun.NewType(stun.MethodConnect, stun.ClassSuccessResponse)
+				if msg.Type.Method == stun.MethodCreatePermission {
+					typ = stun.NewType(stun.MethodCreatePermission, stun.ClassSuccessResponse)
+				}
+
+				msg, err = stun.Build(
+					stun.TransactionID,
+					typ,
+					cid,
+				)
+				assert.NoError(t, err)
+				return TransactionResult{Msg: msg}, nil
+			},
+		}
+		alloc := NewTCPAllocation(&AllocationConfig{
+			Client:      client,
+			Lifetime:    time.Second,
+			Log:         loggerFactory.NewLogger("test"),
+			RelayedAddr: relayedAddr,
+		})
+
+		conn := dummyTCPConn{}
+		dataConn, err := alloc.DialWithConn(conn, "tcp", "127.0.0.1:11111")
+		assert.Equal(t, cid, dataConn.ConnectionID)
+		assert.NoError(t, err)
+	})
+}
diff --git a/internal/client/udp_conn_test.go b/internal/client/udp_conn_test.go
new file mode 100644
index 0000000..9701cfc
--- /dev/null
+++ b/internal/client/udp_conn_test.go
@@ -0,0 +1,78 @@
+// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
+// SPDX-License-Identifier: MIT
+
+package client
+
+import (
+	"net"
+	"testing"
+
+	"github.com/pion/stun"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestUDPConn(t *testing.T) {
+	t.Run("bind()", func(t *testing.T) {
+		client := &mockClient{
+			performTransaction: func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
+				return TransactionResult{}, errFake
+			},
+		}
+
+		bm := newBindingManager()
+		b := bm.create(&net.UDPAddr{
+			IP:   net.ParseIP("127.0.0.1"),
+			Port: 1234,
+		})
+
+		conn := UDPConn{
+			allocation: allocation{
+				client: client,
+			},
+			bindingMgr: bm,
+		}
+
+		err := conn.bind(b)
+		assert.Error(t, err, "should fail")
+		assert.Equal(t, 0, len(bm.chanMap), "should be 0")
+		assert.Equal(t, 0, len(bm.addrMap), "should be 0")
+	})
+
+	t.Run("WriteTo()", func(t *testing.T) {
+		client := &mockClient{
+			performTransaction: func(msg *stun.Message, to net.Addr, dontWait bool) (TransactionResult, error) {
+				return TransactionResult{}, errFake
+			},
+			writeTo: func(data []byte, to net.Addr) (int, error) {
+				return len(data), nil
+			},
+		}
+
+		addr := &net.UDPAddr{
+			IP:   net.ParseIP("127.0.0.1"),
+			Port: 1234,
+		}
+
+		pm := newPermissionMap()
+		assert.True(t, pm.insert(addr, &permission{
+			st: permStatePermitted,
+		}))
+
+		bm := newBindingManager()
+		binding := bm.create(addr)
+		binding.setState(bindingStateReady)
+
+		conn := UDPConn{
+			allocation: allocation{
+				client:  client,
+				permMap: pm,
+			},
+			bindingMgr: bm,
+		}
+
+		buf := []byte("Hello")
+		n, err := conn.WriteTo(buf, addr)
+		assert.NoError(t, err, "should fail")
+		assert.Equal(t, len(buf), n)
+	})
+}
diff --git a/internal/proto/fuzz_test.go b/internal/proto/fuzz_test.go
index f49faed..1bedc5e 100644
--- a/internal/proto/fuzz_test.go
+++ b/internal/proto/fuzz_test.go
@@ -63,28 +63,28 @@ func FuzzSetters(f *testing.F) {
 		m1.Add(attr.t, value)
 		if err := attr.g.GetFrom(m1); err != nil {
 			if errors.Is(err, stun.ErrAttributeNotFound) {
-				fmt.Println("unexpected 404") // nolint
-				panic(err)                    // nolint
+				fmt.Println("unexpected 404") //nolint
+				panic(err)                    //nolint
 			}
 			return
 		}
 
 		m2.WriteHeader()
 		if err := attr.g.AddTo(m2); err != nil {
-			fmt.Println("failed to add attribute to m2") // nolint
-			panic(err)                                   // nolint
+			fmt.Println("failed to add attribute to m2") //nolint
+			panic(err)                                   //nolint
 		}
 
 		m3.WriteHeader()
 		v, err := m2.Get(attr.t)
 		if err != nil {
-			panic(err) // nolint
+			panic(err) //nolint
 		}
 		m3.Add(attr.t, v)
 
 		if !m2.Equal(m3) {
-			fmt.Println(m2, "not equal", m3) // nolint
-			panic("not equal")               // nolint
+			fmt.Println(m2, "not equal", m3) //nolint
+			panic("not equal")               //nolint
 		}
 	})
 }
diff --git a/internal/proto/reqtrans_test.go b/internal/proto/reqtrans_test.go
index d60d294..4a5efee 100644
--- a/internal/proto/reqtrans_test.go
+++ b/internal/proto/reqtrans_test.go
@@ -20,13 +20,19 @@ func TestRequestedTransport(t *testing.T) {
 				"protocol: UDP",
 			)
 		}
+		r = RequestedTransport{
+			Protocol: ProtoTCP,
+		}
+		if r.String() != "protocol: TCP" {
+			t.Errorf("bad string %q, expected %q", r,
+				"protocol: TCP",
+			)
+		}
 		r.Protocol = 254
 		if r.String() != "protocol: 254" {
-			if r.String() != "protocol: UDP" {
-				t.Errorf("bad string %q, expected %q", r,
-					"protocol: 254",
-				)
-			}
+			t.Errorf("bad string %q, expected %q", r,
+				"protocol: 254",
+			)
 		}
 	})
 	t.Run("NoAlloc", func(t *testing.T) {