Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,32 @@
Share file with people on the same network

## Direct transfers

Devices that see each other's shares (same public IP) are usually on the same
local network. When both sides are browsers, clicking **Download** first tries
a direct WebRTC DataChannel between them, signaled over the existing
WebSocket:

- Only LAN host candidates are used: mDNS `.local` names (Chrome, Safari) or
private / link-local addresses (Firefox doesn't hide host candidates behind
mDNS). No STUN, no TURN, no public address ever: the direct path can only
succeed on the actual LAN. The server enforces this too and drops any other
candidate. The only thing a peer in your namespace can learn is your private
LAN address.
- A session can only be opened toward the owner of a share visible in your own
namespace, i.e. exactly what `GET /share/:uuid` already allows.
- The channel is DTLS-encrypted end to end; the server only relays the
handshake and never sees the content.
- If the channel is not open within 5 seconds (guest Wi-Fi client isolation,
VLANs, different networks...), the download silently falls back to the
server relay, exactly as before. `curl`, the QR code and `/all` always use
the relay.

A service worker (`public/sw.js`) streams direct transfers straight into a
regular browser download, so file size is not limited by memory. Without it
(unsupported browser, insecure context), files up to 200 MB are saved from
memory and bigger ones use the relay.

Server logs report how each direct session ended (`RTC session ... closed:
done|timeout|...`), which tells you how often the direct path actually works.

136 changes: 136 additions & 0 deletions lib/dlcenter/client.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'base64'
require 'ipaddr'

module DLCenter
# Input validation constants
Expand All @@ -7,6 +8,13 @@ module DLCenter
MAX_INLINE_CONTENT_LENGTH = 10_000
MAX_SHARES_PER_CLIENT = 100
MAX_CHUNK_SIZE = 2 * 1024 * 1024 # 2MB max chunk size
UUID_FORMAT = /\A[a-f0-9\-]{36}\z/i

# WebRTC signaling limits
MAX_RTC_SESSIONS_PER_CLIENT = 8
MAX_RTC_SDP_LENGTH = 16 * 1024
MAX_RTC_CANDIDATE_LENGTH = 1024
MAX_RTC_CANDIDATES_PER_SESSION = 64

class Client
attr_reader :shares
Expand Down Expand Up @@ -120,6 +128,7 @@ def initialize namespace, ws, &on_close
@ws = ws
@on_close = on_close
@alive = true
@rtc_sessions = {}
@heartbeat_timer = nil
@timeout_timer = nil

Expand Down Expand Up @@ -147,6 +156,7 @@ def initialize namespace, ws, &on_close
guard("onclose") do
puts "WS closed"
stop_heartbeat
close_rtc_sessions
@namespace.remove_client(self)
@on_close.call if @on_close
end
Expand Down Expand Up @@ -209,6 +219,7 @@ def send_msg(msg, params={})
when :hello then send({type: :hello, text: params[:text]}.to_json)
when :stream then send({type: :stream}.merge(params).to_json)
when :stream_close then send({type: :stream_close, uuid: params[:uuid]}.to_json)
when :rtc then send(params.to_json)
else
raise "Invalid msg type #{msg} with params #{params}"
end
Expand Down Expand Up @@ -344,6 +355,127 @@ def handle_chunk(msg)
return true
end

# --- WebRTC signaling ---------------------------------------------------
#
# Two browsers on the same LAN can transfer directly over a DataChannel
# instead of relaying through us. The server only routes the handshake:
# a session can only be opened toward the owner of a share visible in the
# requester's own namespace (the same authorization as GET /share/:uuid),
# session ids are minted here, and peers never learn each other's client
# identity. Only LAN host candidates are relayed (mDNS names or private
# addresses, no STUN/TURN), so the direct path can only succeed on the
# actual local network and no public address is disclosed to anybody.

attr_reader :rtc_sessions

def valid_uuid?(uuid)
uuid.is_a?(String) && uuid.match?(UUID_FORMAT)
end

def valid_rtc_sdp?(sdp)
sdp.is_a?(String) && !sdp.empty? && sdp.length <= MAX_RTC_SDP_LENGTH
end

PRIVATE_RANGES = %w[10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10]
.map { |cidr| IPAddr.new(cidr) }.freeze

# A host candidate that can only be reached from the local network: an
# mDNS name (Chrome, Safari) or a private / link-local address (Firefox
# doesn't obfuscate host candidates by default). Never a public address,
# never a server-reflexive or relay candidate.
def lan_address?(address)
return true if address.match?(/\.local\z/i)
ip = IPAddr.new(address)
PRIVATE_RANGES.any? { |range| range.include?(ip) }
rescue IPAddr::Error, ArgumentError
false
end

# "candidate:<foundation> <component> <proto> <priority> <address> <port> typ <type> ..."
# An empty candidate string is the end-of-candidates marker.
def lan_candidate?(candidate)
return true if candidate.empty?
fields = candidate.split(' ')
fields[6] == 'typ' && fields[7] == 'host' && lan_address?(fields[4].to_s)
end

# Drops any candidate line that is not a LAN host candidate, so that a
# modified client can't advertise a routable address through us.
def scrub_rtc_sdp(sdp)
sdp.each_line.reject do |line|
line.start_with?('a=candidate:') && !lan_candidate?(line.chomp.delete_prefix('a='))
end.join
end

def rtc_open_session(session, peer)
@rtc_sessions[session] = { peer: peer, candidates: 0 }
end

def handle_rtc_offer(msg)
share_uuid = msg[:share]
return puts("Invalid RTC offer") unless valid_uuid?(share_uuid) && valid_rtc_sdp?(msg[:sdp])
return puts("Too many RTC sessions") if @rtc_sessions.size >= MAX_RTC_SESSIONS_PER_CLIENT
share = @namespace.get_share_by_uuid(share_uuid)
owner = share&.client
# IOClient (curl uploads) has no browser to answer, and downloading
# one's own share directly makes no sense: both stay on the relay.
return puts("No RTC peer for share #{share_uuid}") unless owner.is_a?(WSClient) && owner != self
return puts("Peer has too many RTC sessions") if owner.rtc_sessions.size >= MAX_RTC_SESSIONS_PER_CLIENT

session = SecureRandom.uuid
rtc_open_session(session, owner)
owner.rtc_open_session(session, self)
send_msg(:rtc, type: :rtc_session, session: session, share: share_uuid)
owner.send_msg(:rtc, type: :rtc_offer, session: session, share: share_uuid, sdp: scrub_rtc_sdp(msg[:sdp]))
end

def handle_rtc_answer(msg)
state = @rtc_sessions[msg[:session]]
return unless state && valid_rtc_sdp?(msg[:sdp])
state[:peer].send_msg(:rtc, type: :rtc_answer, session: msg[:session], sdp: scrub_rtc_sdp(msg[:sdp]))
end

def handle_rtc_ice(msg)
state = @rtc_sessions[msg[:session]]
return unless state
candidate = msg[:candidate]
return puts("Invalid ICE candidate") unless candidate.is_a?(Hash)
candidate_str = candidate[:candidate]
return puts("Invalid ICE candidate") unless candidate_str.is_a?(String) && candidate_str.length <= MAX_RTC_CANDIDATE_LENGTH
return puts("Dropping non-LAN ICE candidate") unless lan_candidate?(candidate_str)
state[:candidates] += 1
return puts("Too many ICE candidates") if state[:candidates] > MAX_RTC_CANDIDATES_PER_SESSION

# Rebuild the candidate from whitelisted fields only.
forwarded = { candidate: candidate_str }
forwarded[:sdpMid] = candidate[:sdpMid][0, 64] if candidate[:sdpMid].is_a?(String)
forwarded[:sdpMLineIndex] = candidate[:sdpMLineIndex] if candidate[:sdpMLineIndex].is_a?(Integer)
forwarded[:usernameFragment] = candidate[:usernameFragment][0, 256] if candidate[:usernameFragment].is_a?(String)
state[:peer].send_msg(:rtc, type: :rtc_ice, session: msg[:session], candidate: forwarded)
end

def handle_rtc_close(msg)
session = msg[:session]
state = @rtc_sessions.delete(session)
return unless state
# The reason ends up in the logs: keep it to a harmless token.
reason = msg[:reason].is_a?(String) ? msg[:reason].gsub(/[^\w\-]/, '')[0, 32] : 'unknown'
# Instrumentation: how often does the direct path actually work?
puts "RTC session #{session} closed: #{reason}"
state[:peer].rtc_peer_closed(session)
end

def rtc_peer_closed(session)
return unless @rtc_sessions.delete(session)
send_msg(:rtc, type: :rtc_close, session: session)
end

def close_rtc_sessions
sessions = @rtc_sessions
@rtc_sessions = {}
sessions.each { |session, state| state[:peer].rtc_peer_closed(session) }
end

def handle_ws_msg(msg)
#puts msg
case msg[:type]
Expand All @@ -352,6 +484,10 @@ def handle_ws_msg(msg)
when 'chunk' then handle_chunk(msg)
when 'ping' then send({type: :pong}.to_json)
when 'pong' then nil # liveness already recorded in onmessage
when 'rtc_offer' then handle_rtc_offer(msg)
when 'rtc_answer' then handle_rtc_answer(msg)
when 'rtc_ice' then handle_rtc_ice(msg)
when 'rtc_close' then handle_rtc_close(msg)
else puts "Unkown msg : #{msg}"
end
end
Expand Down
7 changes: 7 additions & 0 deletions public/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ a:visited:hover {
}
}

.share__download--busy {
opacity: 0.8;
cursor: progress;
min-width: 9rem;
justify-content: center;
}

.share__remove {
color: var(--red);
cursor: pointer;
Expand Down
7 changes: 4 additions & 3 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@
<script src="js/libs/filesize.js"></script>

<!-- App utilities -->
<script src="js/utils.js?v=2"></script>
<script src="js/utils.js?v=4"></script>
<script src="js/rtc.js?v=3"></script>
</head>

<body class='dropzone'>
<div id="root" class='main-view'></div>

<!-- React components (transpiled by Babel in browser) -->
<script type="text/babel" src="js/components.js?v=2"></script>
<script type="text/babel" src="js/app.js?v=2"></script>
<script type="text/babel" src="js/components.js?v=3"></script>
<script type="text/babel" src="js/app.js?v=4"></script>
</body>

</html>
58 changes: 58 additions & 0 deletions public/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ function App() {
const [localShares, setLocalShares] = useState({});
const [qrModal, setQrModal] = useState(null);
const [textValue, setTextValue] = useState('');
// Per-share download state: { status: connecting|direct|relay|done, progress }
const [downloads, setDownloads] = useState({});
const wsRef = useRef(null);
const pingRef = useRef(null);
const reconnectTimerRef = useRef(null);
Expand Down Expand Up @@ -93,6 +95,42 @@ function App() {
}
}, []);

const setDownload = useCallback((uuid, value) => {
setDownloads(prev => {
const next = { ...prev };
if (value) next[uuid] = value; else delete next[uuid];
return next;
});
}, []);

// Download a remote share: try the direct browser-to-browser path first,
// fall back to the server relay (the plain /share/:uuid link) otherwise.
const downloadShare = useCallback((share) => {
const url = `${downloadHost}/share/${share.uuid}`;
const clearLater = () => setTimeout(() => setDownload(share.uuid, null), 2000);
const relay = () => {
setDownload(share.uuid, { status: 'relay' });
relayDownload(url);
clearLater();
};
const ws = wsRef.current;
if (!rtcSupported() || !ws || ws.readyState !== WebSocket.OPEN) {
relay();
return;
}
setDownload(share.uuid, { status: 'connecting' });
rtcDownload(share, ws, {
onConnected: () => setDownload(share.uuid, { status: 'direct', progress: 0 }),
onProgress: (received, size) => setDownload(share.uuid, { status: 'direct', progress: size ? received / size : 1 }),
onDone: () => { setDownload(share.uuid, { status: 'done' }); clearLater(); },
onAbort: () => setDownload(share.uuid, null),
onFallback: (reason) => {
console.log("direct download unavailable (" + reason + "), using relay");
relay();
}
});
}, [downloadHost, setDownload]);

const handleStream = useCallback((msg) => {
console.log("Should stream file " + msg.share + " to stream " + msg.uuid);
// Use ref for synchronous lookup
Expand Down Expand Up @@ -176,6 +214,7 @@ function App() {
setConnected(false);
setRemoteShares([]);
streamAbortAll();
rtcSocketClosed();
if (pingRef.current) {
clearInterval(pingRef.current);
pingRef.current = null;
Expand Down Expand Up @@ -211,13 +250,30 @@ function App() {
break;
case "pong":
break;
case "rtc_session":
case "rtc_offer":
case "rtc_answer":
case "rtc_ice":
case "rtc_close":
rtcHandleMessage(msg, ws, localSharesRef.current);
break;
default:
console.warn("Unknown message: " + msg.type);
}
};
}, [handleStream, registerLocalShares, scheduleReconnect]);
setupRef.current = setupWebSocket;

// The service worker streams direct transfers straight to disk (see sw.js).
// Optional: without it, small files are saved from memory and big ones
// take the relay.
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.log("service worker unavailable: " + err.message);
});
}, []);

useEffect(() => {
setupWebSocket();
return () => {
Expand Down Expand Up @@ -308,8 +364,10 @@ function App() {
remoteShares={remoteShares}
localShares={localShares}
downloadHost={downloadHost}
downloads={downloads}
onRemove={removeShare}
onShowQR={setQrModal}
onDownload={downloadShare}
/>

{qrModal && (
Expand Down
Loading
Loading