From 145389768d434c46bd24662488294eead7d3addb Mon Sep 17 00:00:00 2001
From: MeiMei <30769358+mei23@users.noreply.github.com>
Date: Sun, 10 May 2020 18:42:31 +0900
Subject: [PATCH] pub-relay (#6341)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* pub-relay

* relay actorをApplicationにする

* Disable koa-compress

* Homeはリレーに送らない

* Disable debug

* UI

* cleanupなど
---
 locales/ja-JP.yml                             |  9 ++
 migration/1589023282116-pubRelay.ts           | 18 ++++
 src/client/app.vue                            |  9 +-
 src/client/pages/instance/relays.vue          | 93 ++++++++++++++++++
 src/client/router.ts                          |  1 +
 src/db/postgre.ts                             |  2 +
 src/misc/gen-key-pair.ts                      | 36 +++++++
 src/models/entities/relay.ts                  | 19 ++++
 src/models/index.ts                           |  2 +
 src/models/repositories/relay.ts              |  6 ++
 src/queue/processors/inbox.ts                 | 10 +-
 .../activitypub/kernel/accept/follow.ts       |  7 ++
 .../activitypub/kernel/reject/follow.ts       |  7 ++
 src/remote/activitypub/misc/ld-signature.ts   |  1 +
 .../activitypub/renderer/follow-relay.ts      | 14 +++
 src/remote/activitypub/renderer/index.ts      | 49 +++++++++-
 src/remote/activitypub/renderer/person.ts     |  3 +-
 src/server/api/endpoints/admin/relays/add.ts  | 24 +++++
 src/server/api/endpoints/admin/relays/list.ts | 20 ++++
 .../api/endpoints/admin/relays/remove.ts      | 24 +++++
 src/services/create-system-user.ts            | 59 ++++++++++++
 src/services/i/pin.ts                         |  2 +
 src/services/i/update.ts                      |  2 +
 src/services/note/create.ts                   |  5 +
 src/services/note/delete.ts                   |  2 +
 src/services/note/polls/update.ts             |  2 +
 src/services/relay.ts                         | 96 +++++++++++++++++++
 27 files changed, 510 insertions(+), 12 deletions(-)
 create mode 100644 migration/1589023282116-pubRelay.ts
 create mode 100644 src/client/pages/instance/relays.vue
 create mode 100644 src/misc/gen-key-pair.ts
 create mode 100644 src/models/entities/relay.ts
 create mode 100644 src/models/repositories/relay.ts
 create mode 100644 src/remote/activitypub/renderer/follow-relay.ts
 create mode 100644 src/server/api/endpoints/admin/relays/add.ts
 create mode 100644 src/server/api/endpoints/admin/relays/list.ts
 create mode 100644 src/server/api/endpoints/admin/relays/remove.ts
 create mode 100644 src/services/create-system-user.ts
 create mode 100644 src/services/relay.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index ffe0a358a..10bbe0a74 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -502,6 +502,10 @@ sidebar: "サイドバー"
 divider: "分割線"
 addItem: "項目を追加"
 rooms: "ルーム"
+relays: "リレー"
+addRelay: "リレーの追加"
+inboxUrl: "inboxのURL"
+addedRelays: "追加済みのリレー"
 
 _theme:
   explore: "テーマを探す"
@@ -1090,3 +1094,8 @@ _pages:
     enviromentVariables: "環境変数"
     pageVariables: "ページ要素"
     argVariables: "入力スロット"
+
+_relayStatus:
+  requesting: "承認待ち"
+  accepted: "承認済み"
+  rejected: "拒否済み"
diff --git a/migration/1589023282116-pubRelay.ts b/migration/1589023282116-pubRelay.ts
new file mode 100644
index 000000000..3b9d35991
--- /dev/null
+++ b/migration/1589023282116-pubRelay.ts
@@ -0,0 +1,18 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class pubRelay1589023282116 implements MigrationInterface {
+    name = 'pubRelay1589023282116'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`CREATE TYPE "relay_status_enum" AS ENUM('requesting', 'accepted', 'rejected')`, undefined);
+        await queryRunner.query(`CREATE TABLE "relay" ("id" character varying(32) NOT NULL, "inbox" character varying(512) NOT NULL, "status" "relay_status_enum" NOT NULL, CONSTRAINT "PK_78ebc9cfddf4292633b7ba57aee" PRIMARY KEY ("id"))`, undefined);
+        await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0d9a1738f2cf7f3b1c3334dfab" ON "relay" ("inbox") `, undefined);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`DROP INDEX "IDX_0d9a1738f2cf7f3b1c3334dfab"`, undefined);
+        await queryRunner.query(`DROP TABLE "relay"`, undefined);
+        await queryRunner.query(`DROP TYPE "relay_status_enum"`, undefined);
+    }
+
+}
diff --git a/src/client/app.vue b/src/client/app.vue
index 170ba9365..5e7396205 100644
--- a/src/client/app.vue
+++ b/src/client/app.vue
@@ -132,7 +132,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle } from '@fortawesome/free-solid-svg-icons';
+import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram } from '@fortawesome/free-solid-svg-icons';
 import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons';
 import { ResizeObserver } from '@juggle/resize-observer';
 import { v4 as uuid } from 'uuid';
@@ -169,7 +169,7 @@ export default Vue.extend({
 			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 			canBack: false,
 			wallpaper: localStorage.getItem('wallpaper') != null,
-			faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer
+			faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
 		};
 	},
 
@@ -413,6 +413,11 @@ export default Vue.extend({
 					text: this.$t('federation'),
 					to: '/instance/federation',
 					icon: faGlobe,
+				}, {
+					type: 'link',
+					text: this.$t('relays'),
+					to: '/instance/relays',
+					icon: faProjectDiagram,
 				}, {
 					type: 'link',
 					text: this.$t('announcements'),
diff --git a/src/client/pages/instance/relays.vue b/src/client/pages/instance/relays.vue
new file mode 100644
index 000000000..568f5edd7
--- /dev/null
+++ b/src/client/pages/instance/relays.vue
@@ -0,0 +1,93 @@
+<template>
+<div class="relaycxt">
+	<portal to="icon"><fa :icon="faProjectDiagram"/></portal>
+	<portal to="title">{{ $t('relays') }}</portal>
+
+	<section class="_card add">
+		<div class="_title"><fa :icon="faPlus"/> {{ $t('addRelay') }}</div>
+		<div class="_content">
+			<mk-input v-model="inbox">
+				<span>{{ $t('inboxUrl') }}</span>
+			</mk-input>
+			<mk-button @click="add(inbox)" primary><fa :icon="faPlus"/> {{ $t('add') }}</mk-button>
+		</div>
+	</section>
+
+	<section class="_card relays">
+		<div class="_title"><fa :icon="faProjectDiagram"/> {{ $t('addedRelays') }}</div>
+		<div class="_content relay" v-for="relay in relays" :key="relay.inbox">
+			<div>{{ relay.inbox }}</div>
+			<div>{{ $t(`_relayStatus.${relay.status}`) }}</div>
+			<mk-button class="button" inline @click="remove(relay.inbox)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button>
+		</div>
+	</section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus, faProjectDiagram } from '@fortawesome/free-solid-svg-icons';
+import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../i18n';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+
+export default Vue.extend({
+	i18n,
+
+	metaInfo() {
+		return {
+			title: this.$t('relays') as string
+		};
+	},
+
+	components: {
+		MkButton,
+		MkInput,
+	},
+
+	data() {
+		return {
+			relays: [],
+			inbox: '',
+			faPlus, faProjectDiagram, faSave, faTrashAlt
+		}
+	},
+
+	created() {
+		this.refresh();
+	},
+
+	methods: {
+		add(inbox: string) {
+			this.$root.api('admin/relays/add', {
+				inbox
+			}).then((relay: any) => {
+				this.refresh();
+			});
+		},
+
+		remove(inbox: string) {
+			this.$root.api('admin/relays/remove', {
+				inbox
+			}).then(() => {
+				this.refresh();
+			});
+		},
+
+		refresh() {
+			this.$root.api('admin/relays/list').then((relays: any) => {
+				this.relays = relays;
+			});
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+._content.relay {
+	div {
+		margin: 0.5em 0;
+	}
+}
+</style>
diff --git a/src/client/router.ts b/src/client/router.ts
index e997d2db9..cf98c57bd 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -58,6 +58,7 @@ export const router = new VueRouter({
 		{ path: '/instance/queue', component: page('instance/queue') },
 		{ path: '/instance/settings', component: page('instance/settings') },
 		{ path: '/instance/federation', component: page('instance/federation') },
+		{ path: '/instance/relays', component: page('instance/relays') },
 		{ path: '/instance/announcements', component: page('instance/announcements') },
 		{ path: '/notes/:note', name: 'note', component: page('note') },
 		{ path: '/tags/:tag', component: page('tag') },
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index 9e3eb3f7d..81fb92f68 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -58,6 +58,7 @@ import { AntennaNote } from '../models/entities/antenna-note';
 import { PromoNote } from '../models/entities/promo-note';
 import { PromoRead } from '../models/entities/promo-read';
 import { program } from '../argv';
+import { Relay } from '../models/entities/relay';
 
 const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
 
@@ -149,6 +150,7 @@ export const entities = [
 	PromoRead,
 	ReversiGame,
 	ReversiMatching,
+	Relay,
 	...charts as any
 ];
 
diff --git a/src/misc/gen-key-pair.ts b/src/misc/gen-key-pair.ts
new file mode 100644
index 000000000..d4a8fa753
--- /dev/null
+++ b/src/misc/gen-key-pair.ts
@@ -0,0 +1,36 @@
+import * as crypto from 'crypto';
+import * as util from 'util';
+
+const generateKeyPair = util.promisify(crypto.generateKeyPair);
+
+export async function genRsaKeyPair(modulusLength = 2048) {
+	return await generateKeyPair('rsa', {
+		modulusLength,
+		publicKeyEncoding: {
+			type: 'spki',
+			format: 'pem'
+		},
+		privateKeyEncoding: {
+			type: 'pkcs8',
+			format: 'pem',
+			cipher: undefined,
+			passphrase: undefined
+		}
+	});
+}
+
+export async function genEcKeyPair(namedCurve: 'prime256v1' | 'secp384r1' | 'secp521r1' | 'curve25519' = 'prime256v1') {
+	return await generateKeyPair('ec', {
+		namedCurve,
+		publicKeyEncoding: {
+			type: 'spki',
+			format: 'pem'
+		},
+		privateKeyEncoding: {
+			type: 'pkcs8',
+			format: 'pem',
+			cipher: undefined,
+			passphrase: undefined
+		}
+	});
+}
diff --git a/src/models/entities/relay.ts b/src/models/entities/relay.ts
new file mode 100644
index 000000000..4c82ccb12
--- /dev/null
+++ b/src/models/entities/relay.ts
@@ -0,0 +1,19 @@
+import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
+import { id } from '../id';
+
+@Entity()
+export class Relay {
+	@PrimaryColumn(id())
+	public id: string;
+
+	@Index({ unique: true })
+	@Column('varchar', {
+		length: 512, nullable: false,
+	})
+	public inbox: string;
+
+	@Column('enum', {
+		enum: ['requesting', 'accepted', 'rejected'],
+	})
+	public status: 'requesting' | 'accepted' | 'rejected';
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index c3b329f4f..e1389e735 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -52,6 +52,7 @@ import { AntennaNote } from './entities/antenna-note';
 import { PromoNote } from './entities/promo-note';
 import { PromoRead } from './entities/promo-read';
 import { EmojiRepository } from './repositories/emoji';
+import { RelayRepository } from './repositories/relay';
 
 export const Announcements = getRepository(Announcement);
 export const AnnouncementReads = getRepository(AnnouncementRead);
@@ -106,3 +107,4 @@ export const Antennas = getCustomRepository(AntennaRepository);
 export const AntennaNotes = getRepository(AntennaNote);
 export const PromoNotes = getRepository(PromoNote);
 export const PromoReads = getRepository(PromoRead);
+export const Relays = getCustomRepository(RelayRepository);
diff --git a/src/models/repositories/relay.ts b/src/models/repositories/relay.ts
new file mode 100644
index 000000000..601bb5eb3
--- /dev/null
+++ b/src/models/repositories/relay.ts
@@ -0,0 +1,6 @@
+import { EntityRepository, Repository } from 'typeorm';
+import { Relay } from '../entities/relay';
+
+@EntityRepository(Relay)
+export class RelayRepository extends Repository<Relay> {
+}
diff --git a/src/queue/processors/inbox.ts b/src/queue/processors/inbox.ts
index f37f663ed..3a0bdbe28 100644
--- a/src/queue/processors/inbox.ts
+++ b/src/queue/processors/inbox.ts
@@ -56,12 +56,10 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
 	}
 
 	// HTTP-Signatureの検証
-	if (!httpSignature.verifySignature(signature, authUser.key.keyPem)) {
-		return 'signature verification failed';
-	}
+	const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem);
 
-	// signatureのsignerは、activity.actorと一致する必要がある
-	if (authUser.user.uri !== activity.actor) {
+	// また、signatureのsignerは、activity.actorと一致する必要がある
+	if (!httpSignatureValidated || authUser.user.uri !== activity.actor) {
 		// 一致しなくても、でもLD-Signatureがありそうならそっちも見る
 		if (activity.signature) {
 			if (activity.signature.type !== 'RsaSignature2017') {
@@ -93,7 +91,7 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
 				return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`;
 			}
 		} else {
-			return 'signature verification failed';
+			throw `skip: http-signature verification failed.`;
 		}
 	}
 
diff --git a/src/remote/activitypub/kernel/accept/follow.ts b/src/remote/activitypub/kernel/accept/follow.ts
index c067f7622..71c1bed9d 100644
--- a/src/remote/activitypub/kernel/accept/follow.ts
+++ b/src/remote/activitypub/kernel/accept/follow.ts
@@ -2,6 +2,7 @@ import { IRemoteUser } from '../../../../models/entities/user';
 import accept from '../../../../services/following/requests/accept';
 import { IFollow } from '../../type';
 import DbResolver from '../../db-resolver';
+import { relayAccepted } from '../../../../services/relay';
 
 export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => {
 	// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
@@ -17,6 +18,12 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<string> =>
 		return `skip: follower is not a local user`;
 	}
 
+	// relay
+	const match = activity.id?.match(/follow-relay\/(\w+)/);
+	if (match) {
+		return await relayAccepted(match[1]);
+	}
+
 	await accept(actor, follower);
 	return `ok`;
 };
diff --git a/src/remote/activitypub/kernel/reject/follow.ts b/src/remote/activitypub/kernel/reject/follow.ts
index 49e82c7af..d97ced46b 100644
--- a/src/remote/activitypub/kernel/reject/follow.ts
+++ b/src/remote/activitypub/kernel/reject/follow.ts
@@ -2,6 +2,7 @@ import { IRemoteUser } from '../../../../models/entities/user';
 import reject from '../../../../services/following/requests/reject';
 import { IFollow } from '../../type';
 import DbResolver from '../../db-resolver';
+import { relayRejected } from '../../../../services/relay';
 
 export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => {
 	// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
@@ -17,6 +18,12 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<string> =>
 		return `skip: follower is not a local user`;
 	}
 
+	// relay
+	const match = activity.id?.match(/follow-relay\/(\w+)/);
+	if (match) {
+		return await relayRejected(match[1]);
+	}
+
 	await reject(actor, follower);
 	return `ok`;
 };
diff --git a/src/remote/activitypub/misc/ld-signature.ts b/src/remote/activitypub/misc/ld-signature.ts
index d61b430f7..070e39edf 100644
--- a/src/remote/activitypub/misc/ld-signature.ts
+++ b/src/remote/activitypub/misc/ld-signature.ts
@@ -70,6 +70,7 @@ export class LdSignature {
 		const transformedData = { ...data };
 		delete transformedData['signature'];
 		const cannonidedData = await this.normalize(transformedData);
+		if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`);
 		const documentHash = this.sha256(cannonidedData);
 		const verifyData = `${optionsHash}${documentHash}`;
 		return verifyData;
diff --git a/src/remote/activitypub/renderer/follow-relay.ts b/src/remote/activitypub/renderer/follow-relay.ts
new file mode 100644
index 000000000..58bc0c90c
--- /dev/null
+++ b/src/remote/activitypub/renderer/follow-relay.ts
@@ -0,0 +1,14 @@
+import config from '../../../config';
+import { Relay } from '../../../models/entities/relay';
+import { ILocalUser } from '../../../models/entities/user';
+
+export function renderFollowRelay(relay: Relay, relayActor: ILocalUser) {
+	const follow = {
+		id: `${config.url}/activities/follow-relay/${relay.id}`,
+		type: 'Follow',
+		actor: `${config.url}/users/${relayActor.id}`,
+		object: 'https://www.w3.org/ns/activitystreams#Public'
+	};
+
+	return follow;
+}
diff --git a/src/remote/activitypub/renderer/index.ts b/src/remote/activitypub/renderer/index.ts
index 63447b0c4..e84a7d90a 100644
--- a/src/remote/activitypub/renderer/index.ts
+++ b/src/remote/activitypub/renderer/index.ts
@@ -1,7 +1,12 @@
 import config from '../../../config';
 import { v4 as uuid } from 'uuid';
+import { IActivity } from '../type';
+import { LdSignature } from '../misc/ld-signature';
+import { ILocalUser } from '../../../models/entities/user';
+import { UserKeypairs } from '../../../models';
+import { ensure } from '../../../prelude/ensure';
 
-export const renderActivity = (x: any) => {
+export const renderActivity = (x: any): IActivity | null => {
 	if (x == null) return null;
 
 	if (x !== null && typeof x === 'object' && x.id == null) {
@@ -11,8 +16,46 @@ export const renderActivity = (x: any) => {
 	return Object.assign({
 		'@context': [
 			'https://www.w3.org/ns/activitystreams',
-			'https://w3id.org/security/v1',
-			{ Hashtag: 'as:Hashtag' }
+			'https://w3id.org/security/v1'
 		]
 	}, x);
 };
+
+export const attachLdSignature = async (activity: any, user: ILocalUser): Promise<IActivity | null> => {
+	if (activity == null) return null;
+
+	const keypair = await UserKeypairs.findOne({
+		userId: user.id
+	}).then(ensure);
+
+	const obj = {
+		// as non-standards
+		manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
+		sensitive: 'as:sensitive',
+		Hashtag: 'as:Hashtag',
+		quoteUrl: 'as:quoteUrl',
+		// Mastodon
+		toot: 'http://joinmastodon.org/ns#',
+		Emoji: 'toot:Emoji',
+		featured: 'toot:featured',
+		// schema
+		schema: 'http://schema.org#',
+		PropertyValue: 'schema:PropertyValue',
+		value: 'schema:value',
+		// Misskey
+		misskey: `${config.url}/ns#`,
+		'_misskey_content': 'misskey:_misskey_content',
+		'_misskey_quote': 'misskey:_misskey_quote',
+		'_misskey_reaction': 'misskey:_misskey_reaction',
+		'_misskey_votes': 'misskey:_misskey_votes',
+		'_misskey_talk': 'misskey:_misskey_talk',
+	};
+
+	activity['@context'].push(obj);
+
+	const ldSignature = new LdSignature();
+	ldSignature.debug = false;
+	activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${config.url}/users/${user.id}#main-key`);
+
+	return activity;
+};
diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts
index 56ff10319..bc8a462d2 100644
--- a/src/remote/activitypub/renderer/person.ts
+++ b/src/remote/activitypub/renderer/person.ts
@@ -13,6 +13,7 @@ import { ensure } from '../../../prelude/ensure';
 
 export async function renderPerson(user: ILocalUser) {
 	const id = `${config.url}/users/${user.id}`;
+	const isSystem = !!user.username.match(/\./);
 
 	const [avatar, banner, profile] = await Promise.all([
 		user.avatarId ? DriveFiles.findOne(user.avatarId) : Promise.resolve(undefined),
@@ -52,7 +53,7 @@ export async function renderPerson(user: ILocalUser) {
 	const keypair = await UserKeypairs.findOne(user.id).then(ensure);
 
 	return {
-		type: user.isBot ? 'Service' : 'Person',
+		type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person',
 		id,
 		inbox: `${id}/inbox`,
 		outbox: `${id}/outbox`,
diff --git a/src/server/api/endpoints/admin/relays/add.ts b/src/server/api/endpoints/admin/relays/add.ts
new file mode 100644
index 000000000..3ea6bcc73
--- /dev/null
+++ b/src/server/api/endpoints/admin/relays/add.ts
@@ -0,0 +1,24 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { addRelay } from '../../../../../services/relay';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'Add relay'
+	},
+
+	tags: ['admin'],
+
+	requireCredential: true as const,
+	requireModerator: true as const,
+
+	params: {
+		inbox: {
+			validator: $.str
+		},
+	},
+};
+
+export default define(meta, async (ps, user) => {
+	return await addRelay(ps.inbox);
+});
diff --git a/src/server/api/endpoints/admin/relays/list.ts b/src/server/api/endpoints/admin/relays/list.ts
new file mode 100644
index 000000000..3b132f73b
--- /dev/null
+++ b/src/server/api/endpoints/admin/relays/list.ts
@@ -0,0 +1,20 @@
+import define from '../../../define';
+import { listRelay } from '../../../../../services/relay';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'List relay'
+	},
+
+	tags: ['admin'],
+
+	requireCredential: true as const,
+	requireModerator: true as const,
+
+	params: {
+	},
+};
+
+export default define(meta, async (ps, user) => {
+	return await listRelay();
+});
diff --git a/src/server/api/endpoints/admin/relays/remove.ts b/src/server/api/endpoints/admin/relays/remove.ts
new file mode 100644
index 000000000..df95e0329
--- /dev/null
+++ b/src/server/api/endpoints/admin/relays/remove.ts
@@ -0,0 +1,24 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { removeRelay } from '../../../../../services/relay';
+
+export const meta = {
+	desc: {
+		'ja-JP': 'Remove relay'
+	},
+
+	tags: ['admin'],
+
+	requireCredential: true as const,
+	requireModerator: true as const,
+
+	params: {
+		inbox: {
+			validator: $.str
+		},
+	},
+};
+
+export default define(meta, async (ps, user) => {
+	return await removeRelay(ps.inbox);
+});
diff --git a/src/services/create-system-user.ts b/src/services/create-system-user.ts
new file mode 100644
index 000000000..7f59efb44
--- /dev/null
+++ b/src/services/create-system-user.ts
@@ -0,0 +1,59 @@
+import * as bcrypt from 'bcryptjs';
+import { v4 as uuid } from 'uuid';
+import generateNativeUserToken from '../server/api/common/generate-native-user-token';
+import { genRsaKeyPair } from '../misc/gen-key-pair';
+import { User } from '../models/entities/user';
+import { UserProfile } from '../models/entities/user-profile';
+import { getConnection } from 'typeorm';
+import { genId } from '../misc/gen-id';
+import { UserKeypair } from '../models/entities/user-keypair';
+import { UsedUsername } from '../models/entities/used-username';
+
+export async function createSystemUser(username: string) {
+	const password = uuid();
+
+	// Generate hash of password
+	const salt = await bcrypt.genSalt(8);
+	const hash = await bcrypt.hash(password, salt);
+
+	// Generate secret
+	const secret = generateNativeUserToken();
+
+	const keyPair = await genRsaKeyPair(4096);
+
+	let account!: User;
+
+	// Start transaction
+	await getConnection().transaction(async transactionalEntityManager => {
+		account = await transactionalEntityManager.save(new User({
+			id: genId(),
+			createdAt: new Date(),
+			username: username,
+			usernameLower: username.toLowerCase(),
+			host: null,
+			token: secret,
+			isAdmin: false,
+			isLocked: true,
+			isBot: true,
+		}));
+
+		await transactionalEntityManager.save(new UserKeypair({
+			publicKey: keyPair.publicKey,
+			privateKey: keyPair.privateKey,
+			userId: account.id
+		}));
+
+		await transactionalEntityManager.save(new UserProfile({
+			userId: account.id,
+			autoAcceptFollowed: false,
+			password: hash,
+		}));
+
+		await transactionalEntityManager.save(new UsedUsername({
+			createdAt: new Date(),
+			username: username.toLowerCase(),
+		}));
+	});
+
+	return account;
+}
diff --git a/src/services/i/pin.ts b/src/services/i/pin.ts
index 9fd7263ff..fcddc5063 100644
--- a/src/services/i/pin.ts
+++ b/src/services/i/pin.ts
@@ -9,6 +9,7 @@ import { Notes, UserNotePinings, Users } from '../../models';
 import { UserNotePining } from '../../models/entities/user-note-pinings';
 import { genId } from '../../misc/gen-id';
 import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
+import { deliverToRelays } from '../relay';
 
 /**
  * 指定した投稿をピン留めします
@@ -87,4 +88,5 @@ export async function deliverPinnedChange(userId: User['id'], noteId: Note['id']
 	const content = renderActivity(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item));
 
 	deliverToFollowers(user, content);
+	deliverToRelays(user, content);
 }
diff --git a/src/services/i/update.ts b/src/services/i/update.ts
index ae72e9134..8d40b08a8 100644
--- a/src/services/i/update.ts
+++ b/src/services/i/update.ts
@@ -4,6 +4,7 @@ import { Users } from '../../models';
 import { User } from '../../models/entities/user';
 import { renderPerson } from '../../remote/activitypub/renderer/person';
 import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
+import { deliverToRelays } from '../relay';
 
 export async function publishToFollowers(userId: User['id']) {
 	const user = await Users.findOne(userId);
@@ -13,5 +14,6 @@ export async function publishToFollowers(userId: User['id']) {
 	if (Users.isLocalUser(user)) {
 		const content = renderActivity(renderUpdate(await renderPerson(user), user));
 		deliverToFollowers(user, content);
+		deliverToRelays(user, content);
 	}
 }
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index f50633792..60a62dcdf 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -31,6 +31,7 @@ import { ensure } from '../../prelude/ensure';
 import { checkHitAntenna } from '../../misc/check-hit-antenna';
 import { addNoteToAntenna } from '../add-note-to-antenna';
 import { countSameRenotes } from '../../misc/count-same-renotes';
+import { deliverToRelays } from '../relay';
 
 type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
 
@@ -349,6 +350,10 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
 					dm.addFollowersRecipe();
 				}
 
+				if (['public'].includes(note.visibility)) {
+					deliverToRelays(user, noteActivity);
+				}
+
 				dm.execute();
 			})();
 		}
diff --git a/src/services/note/delete.ts b/src/services/note/delete.ts
index dc8d23134..11b52cd13 100644
--- a/src/services/note/delete.ts
+++ b/src/services/note/delete.ts
@@ -12,6 +12,7 @@ import { Notes, Users, Instances } from '../../models';
 import { notesChart, perUserNotesChart, instanceChart } from '../chart';
 import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
 import { countSameRenotes } from '../../misc/count-same-renotes';
+import { deliverToRelays } from '../relay';
 
 /**
  * 投稿を削除します。
@@ -48,6 +49,7 @@ export default async function(user: User, note: Note, quiet = false) {
 				: renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), user));
 
 			deliverToFollowers(user, content);
+			deliverToRelays(user, content);
 		}
 
 		// also deliever delete activity to cascaded notes
diff --git a/src/services/note/polls/update.ts b/src/services/note/polls/update.ts
index c076d1304..a33efab66 100644
--- a/src/services/note/polls/update.ts
+++ b/src/services/note/polls/update.ts
@@ -4,6 +4,7 @@ import renderNote from '../../../remote/activitypub/renderer/note';
 import { Users, Notes } from '../../../models';
 import { Note } from '../../../models/entities/note';
 import { deliverToFollowers } from '../../../remote/activitypub/deliver-manager';
+import { deliverToRelays } from '../../relay';
 
 export async function deliverQuestionUpdate(noteId: Note['id']) {
 	const note = await Notes.findOne(noteId);
@@ -16,5 +17,6 @@ export async function deliverQuestionUpdate(noteId: Note['id']) {
 
 		const content = renderActivity(renderUpdate(await renderNote(note, false), user));
 		deliverToFollowers(user, content);
+		deliverToRelays(user, content);
 	}
 }
diff --git a/src/services/relay.ts b/src/services/relay.ts
new file mode 100644
index 000000000..aa3179675
--- /dev/null
+++ b/src/services/relay.ts
@@ -0,0 +1,96 @@
+import { createSystemUser } from './create-system-user';
+import { renderFollowRelay } from '../remote/activitypub/renderer/follow-relay';
+import { renderActivity, attachLdSignature } from '../remote/activitypub/renderer';
+import renderUndo from '../remote/activitypub/renderer/undo';
+import { deliver } from '../queue';
+import { ILocalUser } from '../models/entities/user';
+import { Users, Relays } from '../models';
+import { genId } from '../misc/gen-id';
+
+const ACTOR_USERNAME = 'relay.actor' as const;
+
+export async function getRelayActor(): Promise<ILocalUser> {
+	const user = await Users.findOne({
+		host: null,
+		username: ACTOR_USERNAME
+	});
+
+	if (user) return user as ILocalUser;
+
+	const created = await createSystemUser(ACTOR_USERNAME);
+	return created as ILocalUser;
+}
+
+export async function addRelay(inbox: string) {
+	const relay = await Relays.save({
+		id: genId(),
+		inbox,
+		status: 'requesting'
+	});
+
+	const relayActor = await getRelayActor();
+	const follow = await renderFollowRelay(relay, relayActor);
+	const activity = renderActivity(follow);
+	deliver(relayActor, activity, relay.inbox);
+
+	return relay;
+}
+
+export async function removeRelay(inbox: string) {
+	const relay = await Relays.findOne({
+		inbox
+	});
+
+	if (relay == null) {
+		throw 'relay not found';
+	}
+
+	const relayActor = await getRelayActor();
+	const follow = renderFollowRelay(relay, relayActor);
+	const undo = renderUndo(follow, relayActor);
+	const activity = renderActivity(undo);
+	deliver(relayActor, activity, relay.inbox);
+
+	await Relays.delete(relay.id);
+}
+
+export async function listRelay() {
+	const relays = await Relays.find();
+	return relays;
+}
+
+export async function relayAccepted(id: string) {
+	const result = await Relays.update(id, {
+		status: 'accepted'
+	});
+
+	return JSON.stringify(result);
+}
+
+export async function relayRejected(id: string) {
+	const result = await Relays.update(id, {
+		status: 'rejected'
+	});
+
+	return JSON.stringify(result);
+}
+
+export async function deliverToRelays(user: ILocalUser, activity: any) {
+	if (activity == null) return;
+
+	const relays = await Relays.find({
+		status: 'accepted'
+	});
+	if (relays.length === 0) return;
+
+	const relayActor = await getRelayActor();
+
+	const copy = JSON.parse(JSON.stringify(activity));
+	if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
+
+	const signed = await attachLdSignature(copy, user);
+
+	for (const relay of relays) {
+		deliver(relayActor, signed, relay.inbox);
+	}
+}